// ════════════════════════════════════════════════════════════════════════
//  app.js — Main UTCIForecast component.
//
//  This is the top-level Preact component that owns all state, fetches
//  the forecast, runs the per-hour computations, and renders the page.
//
//  Reading order inside UTCIForecast():
//    1. STATE       (useState calls)       — bits that change on interaction
//    2. EFFECTS     (useEffect calls)      — runs on search / location change
//    3. COMPUTATION (hourlyRows, days, …)  — API data → display rows
//    4. JSX RETURN  (the big html`...`)    — actual page markup
//
//  QUICK MAP
//  ──────────────────────────────────────────────────────────────────────
//    Forecast length .............. fetch URL contains  &forecast_days=14
//    Free tier day limit .......... const FREE_DAYS = 3
//    Preview the Pro view ......... useState(false) on isPro → flip to true
//    Starting location ............ useState({...}) on `location` near top
//    Default columns shown ........ useState({...}) on visibleCols
//    Page tagline / about copy .... search "utci-tagline" or "utci-about-text"
// ════════════════════════════════════════════════════════════════════════

import { h, render, Fragment } from '../vendor/preact.js';
import { useState, useRef, useEffect } from '../vendor/preact-hooks.js';
import htm from '../vendor/htm.js';
import {
  utciCategory, UTCI_BANDS,
  petCategory,
  SKIN_TYPES, sunburnMinutes, burnLabel,
  VEHICLE_TYPES, VEHICLE_SPEEDS, BUILDING_TYPES,
  FUR_COLORS,
  confidenceBand, moonGlyph, skyFillForElev, titleCaseText, scoreFillColor,
} from './utils.js';
import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill, RowStepper } from './components.js';
import { getCellTagEvents, getUpcomingEvents, PRIORITY_WEATHER_IDS } from './events.js';
import { POLLEN_TYPES, COL_DESCRIPTIONS, UTCI_ENVIRONMENTS, FILTER_PROFILES, variantIcons, deriveProfileMain } from './config.js';
import { useAppState } from './hooks/useAppState.js';
import { DayTabs } from './components/DayTabs.js';
import { ConfigPanel } from './components/ConfigPanel.js';
import { WelcomeModal } from './components/WelcomeModal.js';
import { RestoreModal } from './components/RestoreModal.js';
import { buildColumnDefs, airTempRgb, petAirTempRgb } from './tableColumns.js';
import { computeWhyFeelsLike, computeGlanceSummary, computeBestDay, bestDaysLabel, bestDaysHint } from './compute.js';
import { solarElevationDeg } from './physics.js';
import { exportDayXls } from './export.js';

const html = htm.bind(h);

// ── Scope time-lapse playback ───────────────────────────────────────────
// Drives the big scope through the next 24h so you can watch the sky/ground
// gradients and weather evolve. Speed: 15 simulated minutes every 1 real
// second (≈ a full day in ~1.6 minutes). UI advances smoothly every 200ms.
const PLAYBACK_SIM_MS_PER_REAL_MS = 900;          // 15 min / 1 s = 900×
const PLAYBACK_STEP_MS            = 200;          // UI update cadence
const PLAYBACK_WINDOW_MS          = 24 * 60 * 60 * 1000;

// Interpolates the hourly rows to an arbitrary instant (ms). Returns a row
// with smoothly blended elevation / cloud / precip so the scope animates
// continuously rather than snapping hour to hour.
function interpolateRowAt(rows, t) {
  if (!rows || rows.length === 0) return null;
  if (t <= +rows[0].dt) return rows[0];
  if (t >= +rows[rows.length - 1].dt) return rows[rows.length - 1];
  let lo = rows[0], hi = rows[1];
  for (let i = 1; i < rows.length; i++) {
    if (+rows[i].dt >= t) { lo = rows[i - 1]; hi = rows[i]; break; }
  }
  const f = (t - +lo.dt) / (+hi.dt - +lo.dt);
  const L = (a, b) => (a == null || b == null) ? (a ?? b) : a + (b - a) * f;
  return {
    ...lo,
    dt: new Date(t),
    elev:    L(lo.elev, hi.elev),
    glob:    L(lo.glob, hi.glob),
    utciAdj: L(lo.utciAdj, hi.utciAdj),
    // Felt-temp inputs — interpolated so the "Why It Feels" panel breakdown
    // stays smooth and consistent with the (interpolated) utciAdj category.
    Ta:      L(lo.Ta,   hi.Ta),
    Tmrt:    L(lo.Tmrt, hi.Tmrt),
    va:      L(lo.va,   hi.va),
    eh:      L(lo.eh,   hi.eh),
    cc:      L(lo.cc, hi.cc),
    ccLow:   L(lo.ccLow, hi.ccLow),
    ccMid:   L(lo.ccMid, hi.ccMid),
    ccHigh:  L(lo.ccHigh, hi.ccHigh),
    precip:  L(lo.precip, hi.precip),
    snow:    L(lo.snow, hi.snow),
    visKm:   L(lo.visKm, hi.visKm),
  };
}

// Turn a raw fetch failure into something a person can act on. The banner only
// ever appears when nothing at all has loaded (see the loadForecast waterfall
// in hooks/useForecast.js), so "showing your last saved forecast" is never the
// right thing to say here - there isn't one.
function friendlyError(msg) {
  const m = String(msg || '');
  if (typeof navigator !== 'undefined' && navigator.onLine === false) {
    return "You're offline. Reconnect and try again.";
  }
  if (/Failed to fetch|NetworkError|Load failed/i.test(m)) {
    return "Couldn't reach the weather service. Check your connection and try again.";
  }
  const status = m.match(/HTTP (\d{3})/);
  if (status) {
    const code = Number(status[1]);
    if (code === 429) return 'Too many requests just now. Give it a minute and try again.';
    if (code >= 500)  return "The weather service isn't responding. Try again in a moment.";
    return `The weather service rejected the request (error ${code}).`;
  }
  return m;
}

// ── Draggable 24-hour timeline ──────────────────────────────────────────
function DayTimeline({ windowStart, windowEnd, simMs, setSimMs, hourlyRows, utcOffsetMs }) {
  const trackRef    = useRef(null);
  const draggingRef = useRef(false);
  const totalMs     = windowEnd - windowStart;
  const toFrac      = (ms) => Math.max(0, Math.min(1, (ms - windowStart) / totalMs));
  const fraction    = simMs != null ? toFrac(simMs) : null;

  // Find sunrise/sunset within the window by scanning hourly elevation sign changes
  let sunriseMs = null, sunsetMs = null;
  const winRows = hourlyRows.filter(r => +r.dt >= windowStart - 3600000 && +r.dt <= windowEnd + 3600000);
  for (let i = 1; i < winRows.length; i++) {
    const a = winRows[i - 1], b = winRows[i];
    if (a.elev <= 0 && b.elev > 0 && sunriseMs === null) {
      const f = (-a.elev) / (b.elev - a.elev);
      const t = +a.dt + f * (+b.dt - +a.dt);
      if (t >= windowStart && t <= windowEnd) sunriseMs = t;
    }
    if (a.elev > 0 && b.elev <= 0 && sunsetMs === null) {
      const f = a.elev / (a.elev - b.elev);
      const t = +a.dt + f * (+b.dt - +a.dt);
      if (t >= windowStart && t <= windowEnd) sunsetMs = t;
    }
  }
  const srFrac = sunriseMs != null ? toFrac(sunriseMs) : null;
  const ssFrac = sunsetMs  != null ? toFrac(sunsetMs)  : null;

  // Build gradient from actual per-hour sky colours so the bar mirrors what
  // the scope would show at each moment across the window.
  const bgGradient = (() => {
    if (!winRows.length) return '#0a0810';
    const stops = winRows.map(r => {
      const pct  = (toFrac(+r.dt) * 100).toFixed(2);
      const col  = skyFillForElev(r.elev, r.dt.getUTCHours() < 12);
      return `${col} ${pct}%`;
    });
    return `linear-gradient(to right, ${stops.join(', ')})`;
  })();

  // Tick labels at 0h / 6h / 12h / 18h / 24h of the window (actual local clock times)
  const ticks = [0, 6, 12, 18, 24].map(h => {
    const d = new Date(windowStart + h * 3600000 + utcOffsetMs);
    return d.toISOString().slice(11, 16);
  });

  const msFromClientX = (clientX) => {
    const rect = trackRef.current.getBoundingClientRect();
    return windowStart + Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) * totalMs;
  };
  const onPointerDown = (e) => {
    if (e.button !== 0) return;
    e.preventDefault();
    try { trackRef.current.setPointerCapture(e.pointerId); } catch (_) {}
    draggingRef.current = true;
    document.body.classList.add('tl-scrubbing');
    setSimMs(msFromClientX(e.clientX));
  };
  const onPointerMove = (e) => {
    if (!draggingRef.current) return;
    setSimMs(msFromClientX(e.clientX));
  };
  const onPointerUp = () => {
    draggingRef.current = false;
    document.body.classList.remove('tl-scrubbing');
  };
  const onPointerCancel = onPointerUp;

  return html`
    <div class="day-timeline">
      <div class="day-timeline-track"
           ref=${trackRef}
           style=${{ background: bgGradient }}
           onPointerDown=${onPointerDown}
           onPointerMove=${onPointerMove}
           onPointerUp=${onPointerUp}
           onPointerCancel=${onPointerCancel}>
        ${srFrac != null && html`<span class="day-timeline-sun-marker" style=${{ left: `${srFrac * 100}%` }}>↑</span>`}
        ${ssFrac != null && html`<span class="day-timeline-sun-marker" style=${{ left: `${ssFrac * 100}%` }}>↓</span>`}
        ${fraction != null && html`<div class="day-timeline-thumb" style=${{ left: `${fraction * 100}%` }}></div>`}
      </div>
    </div>
  `;
}

export function UTCIForecast() {

  // ── STATE + EFFECTS ───────────────────────────────────────────────────
  // All useState, useEffect, useCallback and useRef logic lives in
  // useAppState. See hooks/useAppState.js for the full reading order.
  const {
    location, setLocationAndSave, recentLocations,
    useMyLocation, locating, locateError, setLocateError,
    shareForecast, shareState,
    forecast, airQuality, aqHorizon, loading, error, now, fetchedAt, normals, retry,
    searchQuery, setSearchQuery, searchResults, setSearchResults, searching,
    selectedDay, setSelectedDay,
    proPromptDay, setProPromptDay,
    proPromptSource, setProPromptSource,
    dayTabsRef, canScrollLeft, canScrollRight, scrollDayTabs,
    isPro, setIsPro,
    activeProfile, setActiveProfile, activateProfile, activeCols,
    visibleCols, setVisibleCols, toggleCol,
	showDecimals, toggleShowDecimals,
    welcomeOpen, closeWelcome, openWelcome,
    restoreOpen, openRestore, closeRestore,
    panelOpen, openPanel, closePanel,
	showUnits, toggleShowUnits,
	tableInterval, setTableInterval,
    forecastView, setForecastView,
    activityOptions, placeOptions, workOptions,
    activityValue, activityLabel, placeValue, placeLabel, workValue, workLabel,
    skinType, setSkinType,
    vehicleType, setVehicleType,
    vehicleVent, setVehicleVent,
    vehicleSpeed, setVehicleSpeed,
    outdoorsVariant, setOutdoorsVariantAndSave,
    buildingType, setBuildingType,
    furColor, setFurColor,
    indoorManaged, setIndoorManaged,
    indoorMode, setIndoorMode,
    pollenType, setPollenTypeAndSave,
    utciEnv, setUtciEnv,
    tableRotated, toggleTableRotated,
    headStickyRef, headTrackRef, headTableRef,
    bodyScrollRef, bodyTableRef, tableWrapRef,
    colPopup, colPopupRef,
    handleThClick, handleThEnter, handleThLeave,
    handlePopupEnter, handlePopupLeave,
    eventTagPopup, eventTagPopupRef,
    evSlideIndex, evTransition, evSlideTo,
    handleEventTagClick, handleEventTagEnter, handleEventTagLeave,
    handleEventTagPopupEnter, handleEventTagPopupLeave,
    closePopup, closeEventTagPopup,
    tableCanScrollLeft, tableCanScrollRight, handleBodyScroll,
    hourlyRows, days, utcOffsetMs,
    visible, tableRows, nowLocalISO, currentRow, currentCat,
    liveElev,
    activeEvents, lensEvent, selectedDayEvents,
  } = useAppState();

  // Search is hidden by default and revealed via the magnifier next to the
  // location. The input then overlays the location line to save space.
  const [searchOpen, setSearchOpen] = useState(false);
  // While closing, keep the overlay mounted so it can fade/close out before
  // unmounting; cleared when the exit animation ends.
  const [searchClosing, setSearchClosing] = useState(false);
  const openSearch = () => { setSearchClosing(false); setSearchOpen(true); };
  const closeSearch = () => { setSearchOpen(false); setSearchClosing(true); };
  const searchWrapRef = useRef(null);

  // ── Event note ───────────────────────────────────────────────────────
  // A content-width strip above the nav showing one active event at a time
  // with its full message, cycling through them with a fade. In flow, so it
  // pushes the page down rather than covering anything (see .event-note).
  const [noteIndex, setNoteIndex]   = useState(0);
  const [noteFading, setNoteFading] = useState(false);
  const NOTE_MS = 8000;
  const NOTE_FADE_MS = 350;

  const noteGoTo = (next) => {
    if (next === noteIndex) return;
    setNoteFading(true);
    setTimeout(() => { setNoteIndex(next); setNoteFading(false); }, NOTE_FADE_MS);
  };

  // ── Dismissal ────────────────────────────────────────────────────────
  // Closing the strip hides it for the rest of the browser session, but only
  // for the events that were showing at the time: the dismissal is stored
  // against a signature of the active ids, so a new event (or a fresh weather
  // warning) brings the strip back rather than staying silently suppressed.
  const NOTE_CLOSE_KEY = 'sunscope_event_note_closed';
  const NOTE_CLOSE_MS  = 260;
  const noteSig = activeEvents.map(e => e.id).sort().join('|');
  const [noteClosedSig, setNoteClosedSig] = useState(() => {
    try { return sessionStorage.getItem(NOTE_CLOSE_KEY) || ''; } catch (e) { return ''; }
  });
  // Kept mounted for the collapse animation, then dropped.
  const [noteClosing, setNoteClosing] = useState(false);
  const noteDismissed = !!noteSig && noteSig === noteClosedSig;

  const noteClose = () => {
    if (noteClosing) return;
    setNoteClosing(true);
    setTimeout(() => {
      try { sessionStorage.setItem(NOTE_CLOSE_KEY, noteSig); } catch (e) { /* ignore */ }
      setNoteClosedSig(noteSig);
      setNoteClosing(false);
    }, NOTE_CLOSE_MS);
  };

  // Keep the index in range if the event list shrinks between forecasts.
  useEffect(() => {
    if (noteIndex >= activeEvents.length) setNoteIndex(0);
  }, [activeEvents.length]);

  useEffect(() => {
    if (activeEvents.length <= 1) return;
    const id = setInterval(() => {
      setNoteFading(true);
      setTimeout(() => {
        setNoteIndex(i => (i + 1) % activeEvents.length);
        setNoteFading(false);
      }, NOTE_FADE_MS);
    }, NOTE_MS);
    return () => clearInterval(id);
  }, [activeEvents.length]);

  const [colTogglesOpen, setColTogglesOpen] = useState(false);
  // Which column pills are offered in the Columns bar. The bar as a whole is
  // Extra-only (see the isPro gate around col-toggles-body), so this just asks
  // "is this column part of the current profile?" — Custom and Show All offer
  // the lot, every other profile offers what it actually uses.
  const colOffered = (key) =>
    activeProfile === 'custom' || activeProfile === 'showall' || !!activeCols[key];
  const searchInputRef = useRef(null);
  const fscScrollRef = useRef(null);

  useEffect(() => {
    const el = fscScrollRef.current;
    if (!el) return;
    let isDown = false, startX = 0, startScroll = 0, hasDragged = false;
    const onMouseDown = (e) => {
      if (!el.contains(e.target) || e.button !== 0) return;
      isDown = true; hasDragged = false;
      startX = e.clientX; startScroll = el.scrollLeft;
      document.body.style.userSelect = 'none';
      document.body.style.webkitUserSelect = 'none';
    };
    const onMouseMove = (e) => {
      if (!isDown) return;
      const dx = e.clientX - startX;
      if (Math.abs(dx) > 5) {
        hasDragged = true;
        el.style.cursor = 'grabbing';
        el.scrollLeft = startScroll - dx;
      }
    };
    const onMouseUp = () => {
      if (!isDown) return;
      isDown = false;
      el.style.cursor = '';
      document.body.style.userSelect = '';
      document.body.style.webkitUserSelect = '';
    };
    const onClickCapture = (e) => {
      if (hasDragged) { e.stopPropagation(); e.preventDefault(); hasDragged = false; }
    };
    document.addEventListener('mousedown', onMouseDown);
    document.addEventListener('mousemove', onMouseMove);
    document.addEventListener('mouseup',   onMouseUp);
    el.addEventListener('click', onClickCapture, true);
    return () => {
      document.removeEventListener('mousedown', onMouseDown);
      document.removeEventListener('mousemove', onMouseMove);
      document.removeEventListener('mouseup',   onMouseUp);
      el.removeEventListener('click', onClickCapture, true);
    };
  }, []);

  const [simpleTemp, setSimpleTemp] = useState('utciAdj');
  useEffect(() => {
    if (activeProfile === 'vehicle' || (activeProfile === 'outdoors' && outdoorsVariant === 'driver')) setSimpleTemp('vehicleT');
    else if (activeProfile === 'home' || (activeProfile === 'outdoors' && outdoorsVariant === 'office')) setSimpleTemp(indoorManaged ? 'managedT' : 'indoorT');
    else if (activeProfile === 'pets') setSimpleTemp('furSurfaceT');
    else setSimpleTemp('utciAdj');
    if (['alltemps', 'showall', 'custom', 'farming', 'construction', 'market', 'windowcleaning', 'office'].includes(activeProfile)) {
      setForecastView('table');
    }
  }, [activeProfile, outdoorsVariant]);

  // Focus the input the moment the search opens.
  useEffect(() => {
    if (searchOpen) searchInputRef.current?.focus();
  }, [searchOpen]);

  // Close the search on outside-click or Escape, clearing any stray query.
  useEffect(() => {
    if (!searchOpen) return;
    const onDown = (e) => {
      if (searchWrapRef.current && !searchWrapRef.current.contains(e.target)) {
        closeSearch();
      }
    };
    const onKey = (e) => {
      if (e.key === 'Escape') { closeSearch(); }
    };
    document.addEventListener('mousedown', onDown);
    document.addEventListener('keydown', onKey);
    return () => {
      document.removeEventListener('mousedown', onDown);
      document.removeEventListener('keydown', onKey);
    };
  }, [searchOpen, setSearchQuery]);

  // ─── SCOPE TIME-LAPSE ──────────────────────────────────────────────────
  // `playing` toggles the time-lapse; `simMs` is the simulated instant shown.
  const [playing, setPlaying] = useState(false);
  const [simMs, setSimMs]     = useState(null);
  // Reset scope to live when the user switches days (scope window is always
  // "now → +24h" regardless of selected day, so a stale scrub position is confusing).
  useEffect(() => { if (!playing) setSimMs(null); }, [selectedDay]);
  useEffect(() => {
    if (!playing) return;
    const start = now.getTime();
    const end   = start + PLAYBACK_WINDOW_MS;
    setSimMs(prev => (prev == null || prev < start || prev > end) ? start : prev);
    const id = setInterval(() => {
      setSimMs(prev => {
        let next = (prev == null ? start : prev) + PLAYBACK_STEP_MS * PLAYBACK_SIM_MS_PER_REAL_MS;
        if (next > end) next = start;   // loop back to "now"
        return next;
      });
    }, PLAYBACK_STEP_MS);
    return () => clearInterval(id);
  }, [playing]);

	// Columns that mark the start of a logical group - used to draw a faint
	// vertical border separating groups in the forecast table.
	const GROUP_ORDER = {
	  felt:    ['utciP', 'shadeT', 'vehicleT', 'indoorT', 'managedT', 'burn', 'utci', 'delta', 'tmrt'],
	  surface: ['concreteT', 'soilT', 'soilT6', 'soilM', 'furSurfaceT', 'pawT'],
	  ambient: ['petShadeT', 'petHomeT', 'air', 'rh', 'dew'],
	  precip:  ['precip', 'precipProb', 'lightning'],
	  sky:     ['cloud', 'vis'],
	  wind:    ['wind', 'dir'],
	  airqual: ['aqi', 'pollen'],
	  solar:   ['uvA', 'uvB', 'sun', 'direct', 'diffuse'],
	};
	const GROUP_OF = Object.fromEntries(
	  Object.entries(GROUP_ORDER).flatMap(([g, keys]) => keys.map(k => [k, g]))
	);
	// Returns col-group-start when this column is the leftmost VISIBLE member
	// of its group. If the canonical first member is hidden the border migrates
	// to the next visible column in the same group.
	const isColVisible = (k) => {
	  if (k === 'indoorT')  return indoorMode === 'on' && !indoorManaged;
	  if (k === 'managedT') return indoorMode === 'on' &&  indoorManaged;
	  return !!visibleCols[k];
	};
	const groupStart = (key) => {
	  const group = GROUP_OF[key];
	  if (!group) return '';
	  const first = GROUP_ORDER[group].find(isColVisible);
	  return first === key ? 'col-group-start' : '';
	};
	// Returns a CSS class encoding the group name - used to tint header cells
	// and group label spans.
	const groupColor = (key) => {
	  const g = GROUP_OF[key];
	  return g ? `grp-${g}` : '';
	};
	// Display names for the column groups — used for the spanning labels
	// above the columns normally, and for the divider rows when rotated.
	const GROUP_LABELS = { felt: 'Felt', surface: 'Surface', ambient: 'Ambient', precip: 'Precip', sky: 'Sky', wind: 'Wind', airqual: 'Air quality', solar: 'Solar' };
	// Builds the group label row above the column headers.
	// Each visible group gets one spanning cell; groups with no visible
	// columns are skipped entirely. Hour always gets a blank lead cell.
	const groupLabelRow = () => {
	  const groups = Object.keys(GROUP_ORDER);
	  const cells = [html`<th class="grp-label-hour" scope="col"><span class="grp-label-date">${glanceDate}</span></th>`];
	  for (const g of groups) {
	    const span = GROUP_ORDER[g].filter(isColVisible).length;
	    if (span === 0) continue;
	    cells.push(html`<th class=${`grp-label grp-label-${g} grp-${g}`} colspan=${span} scope="colgroup">${GROUP_LABELS[g]}</th>`);
	  }
	  return html`<tr class="grp-label-row">${cells}</tr>`;
	};

  // Continuous temperature colour scale — airTempRgb / petAirTempRgb now
  // live in tableColumns.js alongside the cell renderers that use them, and
  // are imported at the top of this file for the thermal-stress legend.
  // ─── 3b. PANEL COMPUTATIONS ──────────────────────────────────────────
  // whyFeelsLike is derived below, after the playback rows are resolved, so
  // the panel can track the simulated instant during play/scrub (see panelRow).

  // Interpolated row at the precise current instant — used for the scope
  // display even outside playback so there's no jump when play is pressed.
  const nowRow = interpolateRowAt(hourlyRows, now.getTime());
  // 24-hour window anchored to now — shared by the play effect and the timeline.
  const windowStart = now.getTime();
  const windowEnd   = windowStart + PLAYBACK_WINDOW_MS;
  // simMs drives the scope whether set by auto-play or by dragging the timeline.
  const simRow      = simMs != null ? interpolateRowAt(hourlyRows, simMs) : null;
  const scopeRow    = simRow || nowRow || currentRow;
  // Elevation computed continuously from the simulated instant so the sun
  // starts exactly where liveElev left off (same solarElevationDeg call).
  const scopeElev   = (simMs != null && location?.lat != null)
    ? solarElevationDeg(location.lat, location.lon, new Date(simMs))
    : (liveElev ?? currentRow?.elev ?? 0);
  const scopeDt     = simRow ? simRow.dt : now;
  const scopeCat    = scopeRow ? utciCategory(scopeRow.utciAdj) : currentCat;
  // "Why It Feels" panel always mirrors the scope dial: the simulated instant
  // during play/scrub, and the interpolated "now" row (scopeRow → nowRow) when
  // stopped. Sharing scopeRow/scopeCat guarantees the panel's thermal label can
  // never disagree with the dial's reticle.
  const panelRow      = scopeRow;
  const panelCat      = scopeCat;
  const whyFeelsLike  = computeWhyFeelsLike(panelRow ?? null, UTCI_ENVIRONMENTS[utciEnv]);
  // Synthesize a storm overlay (lightning) when the simulated hour is wet;
  // outside playback keep the real active event.
  const scopeEvent  = simRow ? ((simRow.precip ?? 0) >= 4 ? { id: 'storm' } : null) : lensEvent;
  // Local clock label for the playback button / timeline. The window is a ROLLING
  // 24h from now so it may cross midnight — prefix weekday ("Wed 14:30").
  const simClock = (simMs != null && scopeDt)
    ? (() => {
        const d = new Date(scopeDt.getTime() + utcOffsetMs);
        const wd = d.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' });
        return `${wd} ${d.toISOString().slice(11, 16)}`;
      })()
    : null;
  const staleThreshMs = isPro ? 15 * 60 * 1000 : 30 * 60 * 1000;
  const isStale       = fetchedAt ? (now - fetchedAt) > staleThreshMs : false;
  // CAMS air quality runs out well before the 14-day forecast does, so the AQI
  // and Pollen columns hit a wall partway along the day tabs. Without saying so
  // the empty cells read as a bug, which is worse than the missing data.
  const aqBeyond = !!(aqHorizon && days[selectedDay]?.key && days[selectedDay].key > aqHorizon);
  const aqBeyondNote = aqBeyond
    ? `Air quality and pollen are only forecast to ${new Date(`${aqHorizon}T00:00:00Z`)
        .toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}`
    : null;
  // ─── TABLE COLUMN REGISTRY ───────────────────────────────────────────
  // One definition per metric (label, unit, group, colouring, formatting),
  // shared by both table orientations — see tableColumns.js. The array
  // order is the column order normally, and the row order when rotated.
  const columnDefs = buildColumnDefs({
    visibleCols, indoorMode, indoorManaged,
    showDecimals, showUnits,
    skinType, utciEnv, pollenType,
    aqBeyond, aqBeyondNote,
  });
  const visibleColumnDefs = columnDefs.filter(d => d.visible);

  const glanceSummary = computeGlanceSummary(
    days[selectedDay]?.rows ?? [],
    activeProfile,
    outdoorsVariant,
    skinType,
    visibleCols,
    vehicleSpeed,
    // Farming uses these for seasonal sow/harvest advice (week's dates + weather).
    activeProfile === 'farming' ? days : null,
    location?.lat,
    // Climate normals + selected day's date drive the "vs seasonal average" row.
    normals,
    days[selectedDay]?.key,
  );
  // Week-scoped, so it is kept out of the day-scoped "At a glance" panel and
  // rendered in its own "The Week Ahead" box beneath it.
  // Scored on the profile's own main field, so the panel answers "when should
  // I drive / when is the house bearable", not always "when is it nice out".
  const weekAhead = computeBestDay(days, nowLocalISO, activeProfile, outdoorsVariant);

  // Human-readable date for the "Day at a glance" heading, e.g. "Mon 2nd June 2026".
  const glanceDate = (() => {
    const key = days[selectedDay]?.key;
    if (!key) return '';
    const d = new Date(key + 'T00:00Z');
    const wd  = d.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' });
    const day = d.getUTCDate();
    const mon = d.toLocaleDateString('en-GB', { month: 'long', timeZone: 'UTC' });
    const yr  = d.getUTCFullYear();
    const ord = (n => { const s = ['th', 'st', 'nd', 'rd'], v = n % 100; return s[(v - 20) % 10] || s[v] || s[0]; })(day);
    return `${wd} ${day}${ord} ${mon} ${yr}`;
  })();

  // Pro: export the selected day's full hourly data as a styled spreadsheet.
  const handleExportDay = () => {
    exportDayXls(visible, {
      locationName: location?.name ?? 'Unknown',
      dateLabel: glanceDate,
      skinType,
    });
  };

  // Day's events surfaced in the "Day at a glance" box. Reuses the same
  // row shape as glanceSummary items: { icon, label, value, alert }.
  const hhmm = (iso) => {
    if (!iso) return '';
    const h = parseInt(iso.slice(11, 13), 10);
    const m = iso.slice(14, 16);
    const period = h < 12 ? 'am' : 'pm';
    const h12 = h % 12 || 12;
    return m === '00' ? `${h12}${period}` : `${h12}:${m}${period}`;
  };
  const hhmmEnd = (iso) => {
    if (!iso) return '';
    const h = parseInt(iso.slice(11, 13), 10) + 1;
    const m = iso.slice(14, 16);
    const period = (h % 24) < 12 ? 'am' : 'pm';
    const h12 = h % 12 || 12;
    return m === '00' ? `${h12}${period}` : `${h12}:${m}${period}`;
  };
  const eventGlanceItems = (selectedDayEvents ?? [])
    .filter(ev => ev.type !== 'promo')
    .map(ev => ({
      icon: ev.emoji,
      label: ev.title,
      value: ev.isoRange
        ? (ev.isoRange[0] === ev.isoRange[1]
            ? hhmm(ev.isoRange[0])
            : `${hhmm(ev.isoRange[0])} – ${hhmmEnd(ev.isoRange[1])}`)
        : (ev.nightOnly ? 'Overnight' : 'All day'),
      alert: false,
    }));

  // ─── 3b. QUICK-VIEW PLOT GEOMETRY ────────────────────────────────────
  // Shared by the simple-view cards and the temperature curve beneath them
  // so the two can never drift apart.
  //
  // The curve is drawn from the full-resolution hourly rows (all 24 hours,
  // never resampled), but each card covers a bucket of `tableInterval`
  // hours and is LABELLED with that bucket's LANDING hour — a 4h card
  // reading "8pm" spans 8–11pm and prints the worst felt temp in that span,
  // which on a cooling evening is 8pm's own value. So a card must sit over
  // its landing hour, not over the bucket's temporal middle (which is where
  // an evenly-divided row of cards puts it: at 4h the "8pm / 28°" card
  // landed above 9:30pm, past sunset, and pointed into the cold green tail).
  //
  // Laying the row out in hour columns fixes that, but a card is `bucket`
  // hours wide while its landing hour sits only half an hour in from the
  // bucket's start, so the first card would hang off the left edge. Hence
  // the inset spacers: a blank half-bucket of track at each end for the
  // outer cards to overhang into.
  //
  // The grid is measured in HALF-hour tracks, because a card centred on its
  // landing hour starts on a half-hour boundary. All tracks are identical,
  // and each card SPANS 2*bucket of them (rather than sitting in one track
  // at width:400%) — spanning divides a card's intrinsic width across the
  // tracks it covers, so the grid's max-content width stays close to what
  // it was and mobile doesn't gain a load of extra horizontal scroll.
  //
  //   tracks   = [ pad ][ hour 0 ][ hour 1 ] … [ hour H-1 ][ pad ]
  //   pad      = bucket half-hours  (>= the (bucket-1)/2 h overhang, + breathing room)
  //   hour j   -> centre at (bucket + 2j + 1) / total
  //   card     -> spans 2*bucket tracks, starting one half-hour after 2j
  //               => centre = 2j + 1 + bucket == hour j's centre  ✓
  const fscPlot = (() => {
    if (!tableRows.length) return null;
    const cards   = tableRows.length;
    const hourly  = visible.length > cards ? visible : tableRows;
    const hours   = hourly.length;
    const bucket  = Math.max(1, tableInterval || 1);
    const total   = 2 * hours + 2 * bucket;   // half-hour tracks
    // Landing-hour index of each card, accumulated so partial buckets at a
    // day boundary stay correct rather than assuming i * bucket.
    const landing = [];
    let j = 0;
    for (const r of tableRows) { landing.push(j); j += r.isoHours ? r.isoHours.length : 1; }
    // A card spans 2*bucket tracks, so this keeps its 55px minimum.
    const unit = 55 / (2 * bucket);
    return {
      hourly, hours, bucket, landing,
      cols: `repeat(${total}, minmax(${unit.toFixed(2)}px, 1fr))`,
      // Fraction of the track width at which hour j's data point sits.
      hourAt: j2 => (bucket + 2 * j2 + 1) / total,
      // grid-column for the card whose landing hour is j (lines are 1-based).
      cardCol: j2 => `${2 * j2 + 2} / span ${2 * bucket}`,
    };
  })();

  // Single colour source for the quick view: the curve's gradient stops AND
  // the cards' thermal tags both read from here, so a card's connector line
  // can never land on a shade its own tag contradicts. (The tags used to take
  // the discrete UTCI_BANDS hex — a flat #90d090 "Comfortable" chip sitting
  // over a yellow-green 22°C point on the continuous ramp.)
  //
  // Pet mode goes through petAirTempRgb, which remaps the pet reading onto
  // the human scale first (petEquivHumanTemp), exactly as the pet table
  // columns and pet legend do.
  const fscRgb = (t, whiteMix) =>
    (simpleTemp === 'furSurfaceT' ? petAirTempRgb : airTempRgb)(t, whiteMix);

  // ─── 4. JSX RETURN ───────────────────────────────────────────────────
  // Everything below is the actual page markup, written as one big HTM
  // template. Search tips:
  //   • "utci-header"       — the top section (title + dial + search)
  //   • "utci-day-tabs"     — the 14 day buttons with band colours
  //   • "col-toggles"       — the column-customisation row (Pro only)
  //   • "utci-table"        — the hourly table itself
  //   • "utci-legend"       — the thermal-stress band legend
  //   • "utci-about"        — the explainer paragraphs at the bottom
  //   • "utci-footer"       — the "reading the table" note
  return html`
    <div class=${`utci-app${activeEvents.length > 0 && !noteDismissed ? ' has-event-note' : ''}`}>
      ${activeEvents.length > 0 && !noteDismissed && (() => {
        const ev = activeEvents[noteIndex] || activeEvents[0];
        const isPriority = PRIORITY_WEATHER_IDS.has(ev.id);
        const fmtDate = (iso) => iso
          ? new Date(iso + 'T00:00Z').toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })
          : null;
        // Date line for one event — computed per slide because every event is
        // rendered (see the stack below), not just the visible one.
        const dateLineFor = (e) => {
          const startFmt = fmtDate(e.start);
          const peakFmt  = fmtDate(e.peak);
          const endFmt   = fmtDate(e.end);
          if (!startFmt || !endFmt) return null;
          if (startFmt === endFmt) return peakFmt ? `Peak ${peakFmt}` : startFmt;
          return (peakFmt && peakFmt !== startFmt && peakFmt !== endFmt)
            ? `${startFmt} – ${endFmt} · Peak ${peakFmt}`
            : `${startFmt} – ${endFmt}`;
        };
        // Warning banners take their background from the day tabs' weather
        // palette (ev.tint, set in events/weather-checks.js) so a heat alert
        // reads the same orange as a hot day tab and a rain alert the same
        // blue. Pastelised hard — the banner is a wide block of body text, so
        // it needs far more headroom than a small tab — and drawn edge-in to
        // echo the tabs' radial "colour radiating inward" look.
        const noteTintStyle = (isPriority && ev.tint) ? (() => {
          const pale = (amt) => `rgb(${ev.tint.map(c => Math.round(c + (255 - c) * amt)).join(',')})`;
          const edge = pale(0.55), core = pale(0.82);
          return {
            background: `linear-gradient(90deg, ${edge} 0%, ${core} 35%, ${core} 65%, ${edge} 100%)`,
            borderColor: `rgb(${ev.tint.map(c => Math.round(c * 0.72)).join(',')})`,
          };
        })() : undefined;
        return html`
          <div class=${`event-note${isPriority ? ' is-priority' : ''}${noteTintStyle ? ' is-tinted' : ''}${noteClosing ? ' is-closing' : ''}`} style=${noteTintStyle}>
            <span class="event-note-accent" style=${{ background: ev.color === '#fdf8ee' ? '#c8922a' : ev.color }} />
            ${/* Every slide is rendered, all stacked in one CSS grid cell, so the
                strip is always as tall as the LONGEST event's copy. Rotating to
                a two-line message then back no longer resizes the banner and
                shunts the page up and down. Only the active slide is visible;
                the rest sit at opacity 0 and are hidden from assistive tech. */''}
            <div class="event-note-stack">
              ${activeEvents.map((e, i) => {
                const dateLine = dateLineFor(e);
                const isActive = i === noteIndex && !noteFading;
                return html`
                  <div
                    key=${e.id || i}
                    class=${`event-note-slide${isActive ? ' is-active' : ''}`}
                    aria-hidden=${isActive ? undefined : 'true'}
                  >
                    <div class="event-note-header">
                      <span class="event-note-emoji">${e.emoji}</span>
                      <span class="event-note-title">${e.title}</span>
                    </div>
                    <div class="event-note-body">
                      <span class="event-note-msg">${e.message}</span>
                      ${dateLine && html`<span class="event-note-dates">${dateLine}</span>`}
                    </div>
                  </div>`;
              })}
            </div>
            ${activeEvents.length > 1 && html`
              <div class="event-note-dots">
                ${activeEvents.map((_, i) => html`
                  <button
                    key=${i}
                    type="button"
                    class=${`event-note-dot${i === noteIndex ? ' active' : ''}`}
                    aria-label=${`Show event ${i + 1} of ${activeEvents.length}`}
                    onClick=${() => noteGoTo(i)}
                  />`)}
              </div>`}
            <button
              type="button"
              class="event-note-close"
              aria-label="Close event banner"
              title="Close"
              onClick=${noteClose}
            >
              <span class="event-note-close-label">Close</span>
              <svg viewBox="0 0 12 12" width="10" height="10" aria-hidden="true">
                <path d="M1.5 1.5 L10.5 10.5 M10.5 1.5 L1.5 10.5"
                      fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
              </svg>
            </button>
          </div>`;
      })()}
      <nav class="utci-topnav" id="site-nav">
        <div class="nav-overlay" onClick=${() => { const n = document.getElementById('site-nav'); n.classList.remove('nav-open'); document.getElementById('nav-drawer').classList.remove('is-open'); }}></div>
        <div class="nav-logo" aria-hidden="true">
          <span class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-n"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-n)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></span>
          <span class="nav-logo-tag">See the world the way your skin does.</span>
        </div>
        <div class="nav-links" id="nav-drawer">
          <a href="./index.html">Forecast</a>
          <div class="nav-dropdown">
            <a href="./about.html" class="nav-dropdown-toggle">About<span class="caret">▾</span></a>
            <div class="nav-dropdown-menu">
              <a href="./about.html">Overview</a>
              <a href="./sunsoak.html">SunSoak & the science</a>
              <a href="./profiles.html">Forecast profiles</a>
              <a href="./columns.html">Column reference</a>
              <a href="./temperatures.html">Derived temperatures</a>
              <a href="./features.html">On-screen features</a>
              <a href="./stress-bands.html">Stress bands</a>
            </div>
          </div>
          <a href="./faq.html">FAQ</a>
          <a href=${isPro
              ? 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'
              : 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'}
             target="_blank" rel="noopener noreferrer">Account</a>
        </div>
        <button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn"
          onClick=${() => {
            const nav = document.getElementById('site-nav');
            const drawer = document.getElementById('nav-drawer');
            const btn = document.getElementById('burger-btn');
            const open = nav.classList.toggle('nav-open');
            drawer.classList.toggle('is-open', open);
            btn.setAttribute('aria-expanded', open ? 'true' : 'false');
          }}>
          <span></span><span></span><span></span>
        </button>
      </nav>

      <main class="utci-shell">
        <div class="utci-header">
          <div class="header-left">
            <h1 class="utci-title">
              <span class="title-sun">Sun</span><svg class="title-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-t"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-t)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="title-scope">Scope</span>
            </h1>
            <div class="utci-tagline">See the world the way your skin does.</div>
            <div class="utci-loc-search" ref=${searchWrapRef}>
              <div class="utci-current-loc">
                <button
                  type="button"
                  class="utci-loc-name"
                  aria-label="Search for a town or city"
                  aria-expanded=${searchOpen}
                  onClick=${() => (searchOpen ? closeSearch() : openSearch())}
                >
                  <svg class="utci-loc-pin" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
                    <path fill="currentColor" d="M12 2c-3.87 0-7 3.13-7 7 0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" />
                    <circle cx="12" cy="9" r="2.5" fill="#fffcf2" />
                  </svg>
                  ${location.name}
                </button>
                <button
                  type="button"
                  class="utci-search-toggle"
                  aria-label="Search for a town or city"
                  aria-expanded=${searchOpen}
                  onClick=${() => (searchOpen ? closeSearch() : openSearch())}
                >
                  <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
                    <circle cx="10.5" cy="10.5" r="6.5" fill="none" stroke="currentColor" stroke-width="2" />
                    <line x1="15.5" y1="15.5" x2="21" y2="21" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
                  </svg>
                </button>
                <button
                  type="button"
                  class=${'utci-loc-action' + (locating ? ' is-busy' : '')}
                  aria-label="Use my current location"
                  title="Use my current location"
                  disabled=${locating}
                  onClick=${useMyLocation}
                >
                  <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
                    <circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2" />
                    <circle cx="12" cy="12" r="1.6" fill="currentColor" />
                    <line x1="12" y1="1.5" x2="12" y2="5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
                    <line x1="12" y1="19" x2="12" y2="22.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
                    <line x1="1.5" y1="12" x2="5" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
                    <line x1="19" y1="12" x2="22.5" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
                  </svg>
                </button>
                <button
                  type="button"
                  class="utci-loc-action"
                  aria-label="Share this forecast"
                  title="Copy a link to this forecast"
                  onClick=${shareForecast}
                >
                  <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
                    <circle cx="18" cy="5" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
                    <circle cx="6" cy="12" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
                    <circle cx="18" cy="19" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
                    <line x1="8.4" y1="10.8" x2="15.6" y2="6.2" stroke="currentColor" stroke-width="2" />
                    <line x1="8.4" y1="13.2" x2="15.6" y2="17.8" stroke="currentColor" stroke-width="2" />
                  </svg>
                </button>
                <span class="utci-loc-coords">
                  ${location.lat.toFixed(3)}°, ${location.lon.toFixed(3)}°
                </span>
              </div>
              ${(locateError || shareState) && html`
                <div class=${'utci-loc-note' + (locateError || shareState === 'failed' ? ' is-warn' : '')} role="status">
                  ${locateError
                    ? locateError
                    : shareState === 'copied' ? 'Link copied' : "Couldn't copy the link"}
                  ${locateError && html`
                    <button type="button" class="utci-loc-note-x" aria-label="Dismiss"
                      onClick=${() => setLocateError(null)}>×</button>`}
                </div>`}
              ${fetchedAt && !loading && html`
                <div class=${'utci-fetch-time' + (isStale ? ' is-stale' : '')}>
                  ${isStale ? html`<span class="stale-dot" title="Data is older than expected - retrying">● </span>` : ''}Last update: ${fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
                </div>`}
              ${(() => {
                const curKey = `${location.lat.toFixed(3)},${location.lon.toFixed(3)}`;
                const recents = (recentLocations || [])
                  .filter((l) => `${l.lat.toFixed(3)},${l.lon.toFixed(3)}` !== curKey)
                  .slice(0, 3);
                if (!recents.length) return null;
                return html`
                  <div class="utci-recent-locs">
                    <span class="utci-recent-label">Recent</span>
                    ${recents.map((l) => html`
                      <button
                        key=${`${l.lat.toFixed(3)}-${l.lon.toFixed(3)}`}
                        type="button"
                        class="utci-recent-chip"
                        title=${`See the forecast for ${l.name}`}
                        onClick=${() => {
                          setLocationAndSave(l);
                          setSelectedDay(0);
                          closeSearch();
                        }}
                      >
                        <svg class="utci-recent-pin" viewBox="0 0 24 24" width="11" height="11" aria-hidden="true">
                          <path fill="currentColor" d="M12 2c-3.87 0-7 3.13-7 7 0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" />
                          <circle cx="12" cy="9" r="2.5" fill="#fffcf2" />
                        </svg>
                        <span class="utci-recent-name">${l.name}</span>
                      </button>`)}
                  </div>`;
              })()}
              ${(searchOpen || searchClosing) && html`
                <div
                  class=${'utci-search-wrap utci-search-overlay' + (searchOpen ? '' : ' is-closing')}
                  onAnimationEnd=${(e) => {
                    if (e.target === e.currentTarget && !searchOpen) {
                      setSearchClosing(false);
                      setSearchQuery('');
                    }
                  }}
                >
                  <input
                    ref=${searchInputRef}
                    class="utci-search"
                    type="text"
                    placeholder="Search any town or city…"
                    value=${searchQuery}
                    onInput=${(e) => setSearchQuery(e.currentTarget.value)}
                  />
                  ${searchResults.length > 0 && html`
                    <div class="utci-results">
                      ${searchResults.map((r) => html`
                        <div
                          key=${`${r.id}-${r.latitude}`}
                          class="utci-result"
                          onClick=${() => {
                            setLocationAndSave({
                              name: `${r.name}${r.admin1 ? ', ' + r.admin1 : ''}`,
                              lat: r.latitude,
                              lon: r.longitude,
                              country: r.country_code,
                            });
                            setSearchQuery('');
                            setSearchResults([]);
                            setSelectedDay(0);
                            closeSearch();
                          }}
                        >
                          <div>${r.name}${r.admin1 ? `, ${r.admin1}` : ''}</div>
                          <div class="utci-result-meta">
                            ${r.country} · ${r.latitude.toFixed(2)}°, ${r.longitude.toFixed(2)}°
                          </div>
                        </div>`)}
                    </div>`}
                  ${searching && html`<div class="utci-searching">Searching…</div>`}
                </div>`}
            </div>
          </div>

          <div class="scope-col">
            <${ScopeReticle}
              value=${scopeRow?.utciAdj ?? null}
              cat=${scopeCat}
              loading=${loading}
              elev=${scopeElev}
              dt=${scopeDt}
              glob=${scopeRow?.glob ?? 0}
              activeEvent=${scopeEvent}
              utciEnvShort=${UTCI_ENVIRONMENTS[utciEnv]?.shortLabel ?? null}
              cc=${scopeRow?.cc ?? null}
              ccLow=${scopeRow?.ccLow ?? null}
              ccMid=${scopeRow?.ccMid ?? null}
              ccHigh=${scopeRow?.ccHigh ?? null}
              precip=${scopeRow?.precip ?? 0}
              snow=${scopeRow?.snow ?? 0}
              visKm=${scopeRow?.visKm ?? null}
              env=${utciEnv}
              wind=${scopeRow?.va ?? 0}
              gust=${scopeRow?.gust ?? 0}
              wd=${scopeRow?.wd ?? null}
            />
            ${hourlyRows.length > 0 && html`
              <div class="scope-daycycle-wrap">
                <div class="scope-daycycle-pill">
                  <button type="button"
                          class=${`scope-play ${(playing || simMs != null) ? 'is-playing' : ''}`}
                          onClick=${() => { if (playing || simMs != null) { setPlaying(false); setSimMs(null); } else setPlaying(true); }}
                          title=${playing ? 'Stop time-lapse' : 'Play a 24-hour time-lapse on the scope'}>
                    <span class="scope-play-icon">${(playing || simMs != null) ? '◼' : '▶'}</span><span class="scope-play-label">${simClock ?? 'Cycle 24h'}</span>
                  </button>
                  <${DayTimeline}
                    windowStart=${windowStart}
                    windowEnd=${windowEnd}
                    simMs=${simMs}
                    setSimMs=${setSimMs}
                    hourlyRows=${hourlyRows}
                    utcOffsetMs=${utcOffsetMs}
                  />
                </div>
              </div>`}
          </div>

          <div class="header-right">
            ${forecast && panelRow && whyFeelsLike && html`
              <div class="insight-panel insight-panel--why">
                <div class="insight-env-footer" data-env=${utciEnv}>
                  <span class="insight-env-footer-label">Solar model</span>
                  <${CustomSelect}
                    value=${utciEnv}
                    isOn=${true}
                    noHide=${true}
                    grpClass="grp-felt"
                    options=${Object.entries(UTCI_ENVIRONMENTS).map(([k, v]) => ({
                      value: k,
                      label: v.label,
                    }))}
                    onChange=${(v) => setUtciEnv(v)}
                  />
                </div>
                <div class="insight-panel-title">
                  Why It Feels
                  ${panelCat && html`<span class="insight-thermal-cat" style=${{ background: panelCat.bg, color: panelCat.fg }}>${panelCat.label}</span>`}
                </div>
                ${(() => {
                  const _wflItems = [
                    { label: 'Air temperature', icon: '🌡', value: panelRow.Ta, isBase: true },
                    { label: 'Sun and sky',    icon: '☀',  value: whyFeelsLike.sunAndSky },
                    { label: 'Wind',           icon: '🌬',  value: whyFeelsLike.wind },
                    { label: 'Humidity',       icon: '💧',  value: whyFeelsLike.humidity },
                    ...(whyFeelsLike.environment !== 0 ? [
                      { label: UTCI_ENVIRONMENTS[utciEnv]?.label ?? 'Environment', icon: '🌍', value: whyFeelsLike.environment }
                    ] : []),
                    ...(whyFeelsLike.precipitation !== 0 ? [
                      { label: 'Precipitation', icon: '🌧', value: whyFeelsLike.precipitation }
                    ] : []),
                  ];
                  const _wflDeltaItems = _wflItems.filter(i => !i.isBase);
                  const _wflMax = Math.max(..._wflDeltaItems.map(i => Math.abs(i.value)), 0.1);
                  return _wflItems.map(({ label, icon, value, isBase }) => html`
                    <div class=${'insight-row' + (isBase ? ' insight-row--base' : '')} key=${label}>
                      <span class="insight-icon">${icon}</span>
                      <span class="insight-label">
                        ${label}
                        ${!isBase && html`<span class="insight-bar-track">
                          <span class=${'insight-bar ' + (value >= 0 ? 'bar-pos' : 'bar-neg')}
                                style=${{ width: `${Math.round(Math.abs(value) / _wflMax * 100)}%` }}></span>
                        </span>`}
                      </span>
                      <span class=${`insight-delta ${isBase ? 'delta-base' : value >= 0 ? 'delta-pos' : 'delta-neg'}`}>
                        ${isBase ? '' : value >= 0 ? '+' : ''}${value.toFixed(1)}°
                      </span>
                    </div>
                  `);
                })()}
              </div>`}
          </div>
		</div>

        ${error && html`
          <div class="utci-status" role="alert" aria-live="polite"
               style=${{ borderColor: '#3a1010', color: '#c44a3a' }}>
            <span class="utci-status-msg">⚠ ${friendlyError(error)}</span>
            <button type="button" class="utci-status-retry" onClick=${retry} disabled=${loading}>
              ${loading ? 'Retrying…' : 'Retry'}
            </button>
          </div>`}
        ${loading && !error && html`
          <div class="acquiring-overlay">
            <div class="acquiring-popup">
              <div class="acquiring-spinner"></div>
              <div class="acquiring-label">Acquiring forecast data…</div>
            </div>
          </div>`}

        ${welcomeOpen && html`<${WelcomeModal} onClose=${closeWelcome} />`}
        ${restoreOpen && html`<${RestoreModal} onClose=${closeRestore} setIsPro=${setIsPro} />`}

        ${forecast && days.length > 0 && (() => {
          const { mainLabel, mainConfigKey } = deriveProfileMain(activeProfile, outdoorsVariant);
          const fabIcon = activeProfile === 'outdoors'
            ? (variantIcons[outdoorsVariant] || '🎯')
            : (FILTER_PROFILES[activeProfile]?.icon || '🎯');
          const fabVal = mainConfigKey === 'vehicle'
              ? (VEHICLE_TYPES[vehicleType]?.name || '')
            : mainConfigKey === 'indoor'
              ? (BUILDING_TYPES[buildingType]?.name || '')
            : mainConfigKey === 'fur'
              ? (FUR_COLORS[furColor]?.name || '')
              : (UTCI_ENVIRONMENTS[utciEnv]?.label || '');
          return html`
            <button
              class=${`floating-profile-btn${panelOpen ? ' is-open' : ''}`}
              onClick=${() => (panelOpen ? closePanel() : openPanel())}
              aria-label="Profile and settings"
              aria-expanded=${panelOpen}
              title="Profile & settings"
            >
              <span class="floating-profile-icon" aria-hidden="true">${fabIcon}</span>
              <span class="floating-profile-val">${mainLabel}</span>
              ${fabVal && html`<span class="floating-profile-sub">· ${fabVal}</span>`}
              <span class="floating-profile-caret" aria-hidden="true">▾</span>
            </button>`;
        })()}

        <${ConfigPanel}
          open=${panelOpen}
          onClose=${closePanel}
          isPro=${isPro}
          activeProfile=${activeProfile} activateProfile=${activateProfile} activeCols=${activeCols}
          activityOptions=${activityOptions} placeOptions=${placeOptions} workOptions=${workOptions}
          activityValue=${activityValue} placeValue=${placeValue} workValue=${workValue}
          outdoorsVariant=${outdoorsVariant} setOutdoorsVariantAndSave=${setOutdoorsVariantAndSave}
          setActiveProfile=${setActiveProfile} setVisibleCols=${setVisibleCols} setIndoorMode=${setIndoorMode}
          setProPromptSource=${setProPromptSource} setProPromptDay=${setProPromptDay}
          indoorManaged=${indoorManaged} setIndoorManaged=${setIndoorManaged}
          buildingType=${buildingType} setBuildingType=${setBuildingType}
          utciEnv=${utciEnv} setUtciEnv=${setUtciEnv}
          vehicleType=${vehicleType} setVehicleType=${setVehicleType}
          vehicleSpeed=${vehicleSpeed} setVehicleSpeed=${setVehicleSpeed}
          vehicleVent=${vehicleVent} setVehicleVent=${setVehicleVent}
          furColor=${furColor} setFurColor=${setFurColor}
          skinType=${skinType} setSkinType=${setSkinType}
          pollenType=${pollenType} setPollenTypeAndSave=${setPollenTypeAndSave}
        />

        ${forecast && days.length > 0 && html`<${DayTabs}
          days=${days}
          selectedDay=${selectedDay} setSelectedDay=${setSelectedDay}
          isPro=${isPro}
          openRestore=${openRestore}
          proPromptDay=${proPromptDay} setProPromptDay=${setProPromptDay}
          proPromptSource=${proPromptSource} setProPromptSource=${setProPromptSource}
          activeProfile=${activeProfile} outdoorsVariant=${outdoorsVariant}
          openPanel=${openPanel}
          vehicleType=${vehicleType} vehicleSpeed=${vehicleSpeed}
          buildingType=${buildingType} indoorManaged=${indoorManaged} utciEnv=${utciEnv} furColor=${furColor}
          dayTabsRef=${dayTabsRef} canScrollLeft=${canScrollLeft} canScrollRight=${canScrollRight}
          scrollDayTabs=${scrollDayTabs}
        />`}

            
            <div class="table-with-rail">
            <div class=${'table-main' + (forecastView === 'simple' ? ' table-main--simple' : '')}>
            <div class="table-toolbar-row">
              <span class="fvt-toolbar-left">
                <span class="forecast-view-toggle-label">View:</span>
                <span class="forecast-view-toggle">
                  <button type="button" title="Quick view – visual card layout" class=${'fvt-btn' + (forecastView === 'simple' ? ' on' : '')} onClick=${() => setForecastView('simple')}>
                    <svg class="fvt-btn-icon" viewBox="0 0 12 12" width="12" height="12"><rect x=".5" y=".5" width="4.5" height="4.5" rx=".8" fill="currentColor"/><rect x="7" y=".5" width="4.5" height="4.5" rx=".8" fill="currentColor"/><rect x=".5" y="7" width="4.5" height="4.5" rx=".8" fill="currentColor"/><rect x="7" y="7" width="4.5" height="4.5" rx=".8" fill="currentColor"/></svg>
                    Quick
                  </button>
                  <button type="button" title="Detailed view – full hourly table" class=${'fvt-btn' + (forecastView === 'table' ? ' on' : '')} onClick=${() => setForecastView('table')}>
                    <svg class="fvt-btn-icon" viewBox="0 0 12 12" width="12" height="12"><rect x=".5" y="1.5" width="11" height="3" rx=".6" fill="currentColor"/><rect x=".5" y="5.5" width="11" height="2.5" rx=".5" fill="currentColor" opacity=".35"/><rect x=".5" y="8.8" width="11" height="2.5" rx=".5" fill="currentColor" opacity=".25"/></svg>
                    Detailed
                  </button>
                </span>
                ${forecastView === 'table' && html`
                  <button type="button"
                    class=${'table-rotate-btn' + (tableRotated ? ' on' : '')}
                    title=${tableRotated ? 'Hours run along the top — click for hours down the side' : 'Hours run down the side — click for hours along the top'}
                    aria-pressed=${tableRotated ? 'true' : 'false'}
                    onClick=${toggleTableRotated}>
                    <svg class="fvt-btn-icon" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true">
                      <path d="M2.4 6.6a3.7 3.7 0 0 1 6-3.2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
                      <path d="M9.6 5.4a3.7 3.7 0 0 1-6 3.2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
                      <path d="M6.3 1.1l2.4 2.2-2.4 1.8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
                      <path d="M5.7 10.9L3.3 8.7l2.4-1.8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
                    </svg>
                    Rotate
                  </button>`}
                <button
                  class=${'col-toggles-edit-btn col-toggles-edit-btn--toolbar' + (colTogglesOpen ? ' col-toggles-edit-btn--open' : '') + (isPro ? '' : ' col-toggles-edit-btn--locked')}
                  title=${isPro ? '' : 'Custom columns are part of SunScope Extra'}
                  onClick=${() => {
                    if (!isPro) { setProPromptSource('columns'); setProPromptDay(0); return; }
                    setColTogglesOpen(v => !v);
                  }}>
                  <span class="col-toggles-edit-btn-label">${!isPro ? '🔒 Edit columns' : colTogglesOpen ? 'Hide Columns' : 'Edit columns'}</span>
                  <svg class="col-toggles-edit-btn-icon" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M2 4l4 4 4-4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>
                </button>
              </span>
              ${forecastView === 'simple' && (() => {
                const showFur    = visibleCols.furSurfaceT;
                const showSolar   = visibleCols.utciP;
                const showShade   = visibleCols.shadeT;
                const showVehicle = visibleCols.vehicleT;
                const showIndoor  = visibleCols.indoorT || visibleCols.managedT;
                if (!showFur && !showSolar && !showShade && !showVehicle && !showIndoor) return null;
                const furOn     = simpleTemp === 'furSurfaceT';
                const solarOn   = simpleTemp === 'utciAdj';
                const shadeOn   = simpleTemp === 'shadeT';
                const vehicleOn = simpleTemp === 'vehicleT';
                const indoorOn  = simpleTemp === 'indoorT' || simpleTemp === 'managedT';
                // Values (fur colour, vehicle type/speed, building type,
                // ventilation) are set in the config strip above the day
                // tabs now — this row is just a tab switcher for which
                // thermal model drives the quick-view cards below. Sits
                // directly above col-toggles in normal flow (touching, zero
                // gap) so it reads as a folder tab attached to that box.
                return html`
                  <span class="fvt-thermal-tabs">
                    ${showSolar && html`<button type="button" class=${'fvt-thermal-tab' + (solarOn ? ' on' : '')} onClick=${() => setSimpleTemp('utciAdj')}>SunSoak</button>`}
                    ${showShade && html`<button type="button" class=${'fvt-thermal-tab' + (shadeOn ? ' on' : '')} onClick=${() => setSimpleTemp('shadeT')}>Shade</button>`}
                    ${showVehicle && html`<button type="button" class=${'fvt-thermal-tab' + (vehicleOn ? ' on' : '')} onClick=${() => setSimpleTemp('vehicleT')}>Vehicle</button>`}
                    ${showIndoor && html`<button type="button" class=${'fvt-thermal-tab' + (indoorOn ? ' on' : '')} onClick=${() => setSimpleTemp(indoorManaged ? 'managedT' : 'indoorT')}>Indoors</button>`}
                    ${showFur && html`<button type="button" class=${'fvt-thermal-tab' + (furOn ? ' on' : '')} onClick=${() => setSimpleTemp('furSurfaceT')}>Fur</button>`}
                  </span>
                `;
              })()}
            </div>
            <div key=${forecastView} class=${'col-toggles-wrap' + (colTogglesOpen ? ' col-toggles-wrap--open' : '')}>
            <div class=${'col-toggles' + (colTogglesOpen ? ' col-toggles--open' : '')}>
                <span class="fvt-interval">
                  <${RowStepper} value=${tableInterval} onChange=${setTableInterval} label="Hours" />
                </span>
                <div class="col-toggles-body">
                ${isPro && html`<${Fragment}>
                ${colOffered('utciP') && html`<button class=${`col-toggle grp-felt${visibleCols.utciP ? ' on' : ''}`} onClick=${() => toggleCol('utciP')}>SunSoak</button>`}
                ${colOffered('vehicleT') && html`<button class=${`col-toggle grp-felt${visibleCols.vehicleT ? ' on' : ''}`} onClick=${() => { if (visibleCols.vehicleT) setVehicleVent(false); toggleCol('vehicleT'); }}>Vehicle</button>`}
                ${(colOffered('indoorT') || colOffered('managedT')) && html`<button class=${`col-toggle grp-felt${indoorMode === 'on' ? ' on' : ''}`} onClick=${() => { if (indoorMode === 'on') { setIndoorMode('off'); setIndoorManaged(false); } else { setIndoorMode('on'); } }}>Indoors</button>`}
                ${colOffered('furSurfaceT') && html`<button class=${`col-toggle grp-surface${visibleCols.furSurfaceT ? ' on' : ''}`} onClick=${() => toggleCol('furSurfaceT')}>Fur</button>`}
                ${colOffered('pawT') && html`<button class=${`col-toggle grp-surface${visibleCols.pawT ? ' on' : ''}`} onClick=${() => toggleCol('pawT')}>Paw</button>`}
                ${colOffered('petShadeT') && html`<button class=${`col-toggle grp-ambient${visibleCols.petShadeT ? ' on' : ''}`} onClick=${() => toggleCol('petShadeT')}>Pet Shade</button>`}
                ${colOffered('petHomeT') && html`<button class=${`col-toggle grp-ambient${visibleCols.petHomeT ? ' on' : ''}`} onClick=${() => toggleCol('petHomeT')}>Pet Home</button>`}
                ${colOffered('burn') && html`<button class=${`col-toggle grp-felt${visibleCols.burn ? ' on' : ''}`} onClick=${() => toggleCol('burn')}>Burn</button>`}
                ${colOffered('utci') && html`<button class=${`col-toggle grp-felt${visibleCols.utci ? ' on' : ''}`} onClick=${() => toggleCol('utci')}>UTCI</button>`}
                ${colOffered('delta') && html`<button class=${`col-toggle grp-felt${visibleCols.delta ? ' on' : ''}`} onClick=${() => toggleCol('delta')}>Δ</button>`}
                ${colOffered('tmrt') && html`<button class=${`col-toggle grp-felt${visibleCols.tmrt ? ' on' : ''}`} onClick=${() => toggleCol('tmrt')}>Tmrt</button>`}
                ${colOffered('concreteT') && html`<button class=${`col-toggle grp-surface${visibleCols.concreteT ? ' on' : ''}`} onClick=${() => toggleCol('concreteT')}>Concrete</button>`}
                ${colOffered('soilT') && html`<button class=${`col-toggle grp-surface${visibleCols.soilT ? ' on' : ''}`} onClick=${() => toggleCol('soilT')}>Soil °C</button>`}
                ${colOffered('soilT6') && html`<button class=${`col-toggle grp-surface${visibleCols.soilT6 ? ' on' : ''}`} onClick=${() => toggleCol('soilT6')}>Soil 6cm</button>`}
                ${colOffered('soilM') && html`<button class=${`col-toggle grp-surface${visibleCols.soilM ? ' on' : ''}`} onClick=${() => toggleCol('soilM')}>Soil moist</button>`}
                ${colOffered('shadeT') && html`<button class=${`col-toggle grp-ambient${visibleCols.shadeT ? ' on' : ''}`} onClick=${() => toggleCol('shadeT')}>Shade</button>`}
                ${colOffered('air') && html`<button class=${`col-toggle grp-ambient${visibleCols.air ? ' on' : ''}`} onClick=${() => toggleCol('air')}>Air</button>`}
                ${colOffered('rh') && html`<button class=${`col-toggle grp-ambient${visibleCols.rh ? ' on' : ''}`} onClick=${() => toggleCol('rh')}>RH</button>`}
                ${colOffered('dew') && html`<button class=${`col-toggle grp-ambient${visibleCols.dew ? ' on' : ''}`} onClick=${() => toggleCol('dew')}>Dew</button>`}
                ${colOffered('precip') && html`<button class=${`col-toggle grp-precip${visibleCols.precip ? ' on' : ''}`} onClick=${() => toggleCol('precip')}>Precip</button>`}
                ${colOffered('precipProb') && html`<button class=${`col-toggle grp-precip${visibleCols.precipProb ? ' on' : ''}`} onClick=${() => toggleCol('precipProb')}>Rain%</button>`}
                ${colOffered('lightning') && html`<button class=${`col-toggle grp-precip${visibleCols.lightning ? ' on' : ''}`} onClick=${() => toggleCol('lightning')}>Lightning</button>`}
                ${colOffered('cloud') && html`<button class=${`col-toggle grp-sky${visibleCols.cloud ? ' on' : ''}`} onClick=${() => toggleCol('cloud')}>Cloud</button>`}
                ${colOffered('vis') && html`<button class=${`col-toggle grp-sky${visibleCols.vis ? ' on' : ''}`} onClick=${() => toggleCol('vis')}>Visibility</button>`}
                ${colOffered('wind') && html`<button class=${`col-toggle grp-wind${visibleCols.wind ? ' on' : ''}`} onClick=${() => toggleCol('wind')}>Wind</button>`}
                ${colOffered('dir') && html`<button class=${`col-toggle grp-wind${visibleCols.dir ? ' on' : ''}`} onClick=${() => toggleCol('dir')}>Dir</button>`}
                ${colOffered('aqi') && html`<button class=${`col-toggle grp-airqual${visibleCols.aqi ? ' on' : ''}`} onClick=${() => toggleCol('aqi')}>Air Quality</button>`}
                ${colOffered('pollen') && html`<button class=${`col-toggle grp-airqual${visibleCols.pollen ? ' on' : ''}`} onClick=${() => toggleCol('pollen')}>Pollen</button>`}
                ${colOffered('uvA') && html`<button class=${`col-toggle grp-solar${visibleCols.uvA ? ' on' : ''}`} onClick=${() => toggleCol('uvA')}>UV-A</button>`}
                ${colOffered('uvB') && html`<button class=${`col-toggle grp-solar${visibleCols.uvB ? ' on' : ''}`} onClick=${() => toggleCol('uvB')}>UV-B</button>`}
                ${colOffered('sun') && html`<button class=${`col-toggle grp-solar${visibleCols.sun ? ' on' : ''}`} onClick=${() => toggleCol('sun')}>Sun</button>`}
                ${colOffered('direct') && html`<button class=${`col-toggle grp-solar${visibleCols.direct ? ' on' : ''}`} onClick=${() => toggleCol('direct')}>Direct</button>`}
                ${colOffered('diffuse') && html`<button class=${`col-toggle grp-solar${visibleCols.diffuse ? ' on' : ''}`} onClick=${() => toggleCol('diffuse')}>Diffuse</button>`}
                </${Fragment}>`}

                <span class="col-toggles-display-opts">
                  <label class="display-opt">
                    <input type="checkbox" checked=${showDecimals} onChange=${toggleShowDecimals} />
                    Decimals
                  </label>
                  <label class="display-opt">
                    <input type="checkbox" checked=${showUnits} onChange=${toggleShowUnits} />
                    Units
                  </label>
                </span>
                </div>
            </div>
            </div>

            <div class="forecast-simple-wrap">
              <div class="forecast-simple-scroll" ref=${fscScrollRef}>
                <div class="forecast-simple-inner">
                <div class="forecast-simple-cards" style=${{ gridTemplateColumns: fscPlot?.cols }}>
                ${tableRows.map((r, ri) => {
                  const dispTemp = r[simpleTemp] ?? r.utciAdj;
                  const cat = simpleTemp === 'furSurfaceT' ? petCategory(dispTemp) : utciCategory(dispTemp);
                  const h24s = parseInt(r.iso.slice(11, 13), 10);
                  const localHHMMs = h24s === 0 ? '12am' : h24s < 12 ? `${h24s}am` : h24s === 12 ? '12pm' : `${h24s - 12}pm`;
                  const isNow = r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO;
                  const domIcon = (() => {
                    if (r.precipProb > 20 && (r.precip > 0 || r.snow > 0))
                      return html`<${PrecipIcon} precip=${r.precip} snow=${r.snow} size=${42} filled=${true} />`;
                    if (r.cloudCat && r.cloudCat !== 'clear')
                      return html`<${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${42} filled=${true} showSun=${false} />`;
                    return null;
                  })();
                  const windMph = Math.round((r.gust ?? r.va) * 2.237);
                  // Tag shade = the exact gradient stop the curve is painted
                  // with at this card's landing hour (fsc-grad, whiteMix 0.42),
                  // so the connector line lands on the colour the tag is
                  // wearing. Band label and weight still come from the band.
                  const feelRgb = fscRgb(dispTemp, 0.42) || [200, 200, 200];
                  const feelLum = (feelRgb[0] * 299 + feelRgb[1] * 587 + feelRgb[2] * 114) / 1000;
                  const feelStyle = {
                    background: `rgb(${feelRgb[0]},${feelRgb[1]},${feelRgb[2]})`,
                    color: feelLum > 165 ? '#1a1a1a' : '#ffffff',
                    ...(cat.fontWeight ? { fontWeight: cat.fontWeight } : {}),
                  };
                  // Card wears the same hue as its feel tag, washed right back so
                  // the tag still reads as the strong swatch on top of it.
                  const cardRgb = fscRgb(dispTemp, 0.70) || [245, 245, 245];
                  // Border is the same hue a few shades down, so it edges the card
                  // without introducing a colour that isn't already on it.
                  const edgeRgb = fscRgb(dispTemp, 0.40) || [200, 200, 200];
                  return html`
                    <div key=${r.iso} class=${'fsc-card' + (isNow ? ' fsc-card--now' : '')}
                         style=${{
                           background: `rgb(${cardRgb[0]},${cardRgb[1]},${cardRgb[2]})`,
                           ...(isNow ? {} : { border: `1px solid rgb(${edgeRgb[0]},${edgeRgb[1]},${edgeRgb[2]})` }),
                           ...(fscPlot ? { gridColumn: fscPlot.cardCol(fscPlot.landing[ri]) } : {}),
                         }}>
                      <div class="fsc-time">${localHHMMs}</div>
                      <div class="fsc-scope-wrap">
                        <${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${42} />
                        ${domIcon && html`<div class="fsc-wx-overlay">${domIcon}</div>`}
                      </div>
                      <div class="fsc-meta">
                        <span class="fsc-meta-row fsc-meta-rain">${r.precipProb}%</span>
                        <span class="fsc-meta-row fsc-meta-wind">${windMph}mph</span>
                        <span class="fsc-meta-row fsc-meta-dir">${r.compass ? r.compass.label : '—'}</span>
                      </div>
                      <div class="fsc-temp">${Math.round(dispTemp)}°</div>
                      <div class="fsc-feel" style=${feelStyle}>${cat.label}</div>
                    </div>
                  `;
                })}
                </div>
              ${(() => {
                if (!fscPlot) return null;
                const fscSvgW = 1000, fscSvgH = 80;
                const fscSrc = fscPlot.hourly;
                // Fixed scale: bottom = Freezing band bottom (−10) − 10, top = Danger start (44) + 10
                const fscMin = -15, fscMax = 45;
                const toY = t => fscSvgH - ((t - fscMin) / (fscMax - fscMin)) * fscSvgH;
                const getT = r => r[simpleTemp] ?? r.utciAdj;
                // Same hour->x mapping the cards grid uses (see fscPlot above), so a
                // card's centre and its hour's data point are the same x by
                // construction — every hour of the day stays on screen.
                const fscHourX = j => fscPlot.hourAt(j) * fscSvgW;
                const fscStopPct = j => (fscPlot.hourAt(j) * 100).toFixed(1);
                const fscAllPts = fscSrc.map((r, j) => ({ x: fscHourX(j), y: toY(getT(r)) }));
                // Connector points — one per card, planted on its landing hour's data
                // point, which is exactly where that card is centred.
                const fscPts = fscPlot.landing.map(j => fscAllPts[Math.min(j, fscAllPts.length - 1)]);
                const fscNowFlags = tableRows.map(r => r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO);
                // Flat runs from each edge into the first/last hour, so the fill still
                // covers the inset spacer strips at both ends.
                const fscFillPts = [
                  { x: 0, y: fscAllPts[0].y },
                  ...fscAllPts,
                  { x: fscSvgW, y: fscAllPts[fscAllPts.length - 1].y },
                ];
                let fscFillLine = `M ${fscFillPts[0].x},${fscFillPts[0].y}`;
                for (let i = 1; i < fscFillPts.length; i++) {
                  const p0 = fscFillPts[i - 1], p1 = fscFillPts[i];
                  const cpx = (p0.x + p1.x) / 2;
                  fscFillLine += ` C ${cpx},${p0.y} ${cpx},${p1.y} ${p1.x},${p1.y}`;
                }
                const fscFill = fscFillLine + ` L ${fscSvgW},${fscSvgH} L 0,${fscSvgH} Z`;
                return html`
                  <svg class="fsc-temp-curve" viewBox="0 0 ${fscSvgW} ${fscSvgH}" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
                    <defs>
                      <linearGradient id="fsc-grad" x1="0" y1="0" x2="1" y2="0" gradientUnits="objectBoundingBox">
                        ${fscSrc.map((r, j) => {
                          const rgb = fscRgb(getT(r), 0.42) || [200, 200, 200];
                          const fc = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
                          return html`<stop key=${j} offset=${`${fscStopPct(j)}%`} stop-color=${fc} />`;
                        })}
                      </linearGradient>
                      <linearGradient id="fsc-grad-strong" x1="0" y1="0" x2="1" y2="0" gradientUnits="objectBoundingBox">
                        ${fscSrc.map((r, j) => {
                          const rgb = fscRgb(getT(r), 0.25) || [200, 200, 200];
                          const fc = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
                          return html`<stop key=${`s${j}`} offset=${`${fscStopPct(j)}%`} stop-color=${fc} />`;
                        })}
                      </linearGradient>
                    </defs>
                    ${[[0, '#4a90c4'], [20, '#4a9e6a']].map(([t, color]) => {
                      const gy = toY(t);
                      return html`
                        <line key=${'g'+t} x1="0" y1=${gy} x2=${fscSvgW} y2=${gy}
                          stroke=${color} stroke-width="1" opacity="0.35"
                          vector-effect="non-scaling-stroke" />
                      `;
                    })}
                    ${fscPts.map((pt, i) => {
                      const isNow = fscNowFlags[i];
                      return html`
                        <line key=${'c'+i} x1=${pt.x} y1="0" x2=${pt.x} y2=${pt.y}
                          stroke=${isNow ? '#c8922a' : '#b09870'}
                          stroke-width="1"
                          opacity=${isNow ? '1' : '0.55'}
                          vector-effect="non-scaling-stroke"
                        />
                      `;
                    })}
                    <path d=${fscFill} fill="url(#fsc-grad)" opacity="0.45" />
                    <path d=${fscFill} fill="none" stroke="url(#fsc-grad-strong)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke" />
                  </svg>
                `;
              })()}
                </div>
              </div>
              <div class="fsc-table-btn-wrap">
                <button type="button" class="fsc-table-btn" onClick=${() => setForecastView('table')}>
                  Detailed Hourly Forecast →
                </button>
              </div>
            </div>

            ${(() => {
              // ── THE HOURLY TABLE, IN EITHER ORIENTATION ──────────────
              // Normally hours run down the page and metrics across it.
              // Rotated, the axes swap: hours along the top, metrics down
              // the side. Both read the same visibleColumnDefs registry
              // (see tableColumns.js), so the cells are identical either
              // way — only the axis they are laid out on changes.

              // Is this hour "now", and is the sun below the horizon?
              const hourFlags = (r) => ({
                isNight: r.elev < 0,
                isNow: r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO,
              });

              // The scope + "3pm" + event tags cell. Leads each row
              // normally; heads each column when rotated.
              const timeCell = (r) => {
                // r.iso is the local wall-clock string from the API — slice it directly.
                const h24 = parseInt(r.iso.slice(11, 13), 10);
                const localHHMM = h24 === 0 ? '12am' : h24 < 12 ? `${h24}am` : h24 === 12 ? '12pm' : `${h24 - 12}pm`;
                const rowEvents = getCellTagEvents(selectedDayEvents, r);
                return html`
                  <span class="utci-time-inner">
                    <${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${38} />
                    <span>${localHHMM}</span>
                    ${rowEvents.length > 0 && html`
                      <span class="utci-time-tags">
                        ${rowEvents.map((ev, idx) => html`
                          <span key=${ev.id} class="event-cell-tag"
                            onClick=${(e) => handleEventTagClick(rowEvents, idx, e)}
                            onMouseEnter=${(e) => handleEventTagEnter(rowEvents, idx, e)}
                            onMouseLeave=${handleEventTagLeave}
                            role="button" tabIndex="0" aria-label=${ev.title}
                          >${ev.emoji}</span>
                        `)}
                      </span>`}
                  </span>`;
              };

              // ── ROTATED: hours across the top, metrics down the side ──
              // One table, unlike the normal orientation's split pair.
              // That split exists purely to let the header stick to the
              // viewport while the body scrolls sideways; here the hour
              // row deliberately scrolls away with the table, so there is
              // nothing to keep in step and no reason to pay for it. The
              // metric column still pins on the left — plain CSS sticky,
              // which works inside the horizontal scroller.
              //
              // The key matters: both orientations root at a <div>, so
              // without distinct keys Preact diffs one into the other and
              // reuses the DOM nodes — carrying over the inline column
              // widths and header transforms that the width-sync leaves
              // behind, which is what knocks the columns out of
              // alignment when you flip back.
              if (tableRotated) return html`
                <div key="table-rotated" class=${`utci-table-wrap is-rotated${tableCanScrollLeft ? ' scroll-fade-left' : ''}${tableCanScrollRight ? ' scroll-fade-right' : ''}${showUnits ? '' : ' no-units'}`} ref=${tableWrapRef}>
                  <span class="utci-scroll-chevron left" aria-hidden="true">‹</span>
                  <span class="utci-scroll-chevron right" aria-hidden="true">›</span>

                  <div class="utci-tbody-scroll" ref=${bodyScrollRef}>
                    <table class="utci-table utci-table-rotated" ref=${bodyTableRef}>
                      <thead>
                        <tr>
                          <th class="rot-corner col-info-th" scope="col"
                              onClick=${(e) => handleThClick('hour', e)}
                              onMouseEnter=${(e) => handleThEnter('hour', e)}
                              onMouseLeave=${handleThLeave}>
                            <span class="rot-corner-date">${glanceDate}</span>
                            <span class="rot-corner-controls">
                              <span class="hour-interval" onClick=${(e) => e.stopPropagation()}>
                                <${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} />
                              </span>
                            </span>
                          </th>
                          ${tableRows.map(r => {
                            const f = hourFlags(r);
                            return html`
                              <th key=${r.iso} scope="col"
                                  class=${`rot-hour-th ${f.isNight ? 'is-night' : ''} ${f.isNow ? 'is-now' : ''}`.replace(/\s+/g, ' ').trim()}>
                                ${timeCell(r)}
                              </th>`;
                          })}
                        </tr>
                      </thead>
                      <tbody>
                        ${visibleColumnDefs.map((d, i) => {
                          // A group heading row is emitted whenever the
                          // group changes, standing in for the spanning
                          // group labels above the columns normally.
                          const newGroup = d.group && d.group !== (i > 0 ? visibleColumnDefs[i - 1].group : null) ? d.group : null;
                          return html`
                            <${Fragment} key=${d.key}>
                              ${newGroup && html`
                                <tr class="grp-label-row grp-label-row--rotated">
                                  <th class=${`grp-label rot-group-label grp-label-${newGroup} grp-${newGroup}`} scope="rowgroup">
                                    ${GROUP_LABELS[newGroup]}
                                  </th>
                                  ${/* Real cells per hour rather than one spanning cell: a
                                        colspan leaves nothing sitting in the "now" column, so
                                        the brass bracket running down it breaks at every group
                                        divider. These also let the label pin like a metric name. */
                                    tableRows.map(r => html`
                                      <td key=${r.iso} class=${`grp-label-fill grp-${newGroup} ${hourFlags(r).isNow ? 'is-now' : ''}`.trim()}></td>`)}
                                </tr>`}
                              <tr class="rot-metric-row">
                                <th scope="row"
                                    class=${`rot-metric-label col-info-th ${groupColor(d.key)}`.trim()}
                                    onClick=${(e) => handleThClick(d.key, e)}
                                    onMouseEnter=${(e) => handleThEnter(d.key, e)}
                                    onMouseLeave=${handleThLeave}>
                                  ${d.label} <span class=${d.unitNone ? 'col-unit col-unit--none' : 'col-unit'}>${d.unit}</span>${d.headExtra ?? null}
                                </th>
                                ${tableRows.map((r, hi) => {
                                  const c = d.render(r, tableRows[hi - 1], tableRows[hi + 1], 'to right');
                                  const f = hourFlags(r);
                                  return html`<td key=${r.iso}
                                    class=${`${d.cellClass ?? ''} ${f.isNight ? 'is-night' : ''} ${f.isNow ? 'is-now' : ''}`.replace(/\s+/g, ' ').trim()}
                                    title=${c.title ?? null}
                                    style=${c.style ?? null}>${c.content}</td>`;
                                })}
                              </tr>
                            </${Fragment}>`;
                        })}
                      </tbody>
                    </table>
                  </div>
                </div>`;

              // ── NORMAL: hours down the side, metrics across the top ───
              // Split into a sticky header table and a scrolling body
              // table whose column widths useTableScroll keeps in step.
              return html`
            <div key="table-normal" class=${`utci-table-wrap${tableCanScrollLeft ? ' scroll-fade-left' : ''}${tableCanScrollRight ? ' scroll-fade-right' : ''}${showUnits ? '' : ' no-units'}`} ref=${tableWrapRef}>
              <span class="utci-scroll-chevron left" aria-hidden="true">‹</span>
              <span class="utci-scroll-chevron right" aria-hidden="true">›</span>

              <div class="utci-thead-sticky" ref=${headStickyRef}>
                <div class="utci-thead-track" ref=${headTrackRef}>
                  <table class="utci-table utci-table-head" ref=${headTableRef}>
                    <thead>
                      ${groupLabelRow()}
                      <tr>
                        <th class="col-info-th" scope="col" onClick=${(e) => handleThClick('hour', e)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}>
                          <span>Rows</span>
                          <span class="hour-interval" onClick=${(e) => e.stopPropagation()}>
                            <${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} />
                          </span>
                        </th>
                        ${visibleColumnDefs.map(d => html`
                          <th key=${d.key} scope="col"
                              class=${`col-info-th ${d.headClass ?? ''} ${groupStart(d.key)} ${groupColor(d.key)}`.replace(/\s+/g, ' ').trim()}
                              onClick=${(e) => handleThClick(d.key, e)}
                              onMouseEnter=${(e) => handleThEnter(d.key, e)}
                              onMouseLeave=${handleThLeave}>
                            ${d.label} <span class=${d.unitNone ? 'col-unit col-unit--none' : 'col-unit'}>${d.unit}</span>${d.headExtra ?? null}
                          </th>`)}
                      </tr>
                    </thead>
                  </table>
                </div>
              </div>

              <div class="utci-tbody-scroll" ref=${bodyScrollRef} onScroll=${handleBodyScroll}>
                <table class="utci-table utci-table-body" ref=${bodyTableRef}>
                  <tbody>
                  ${tableRows.map((r, rowIdx) => {
                    const f = hourFlags(r);
                    return html`
                      <tr key=${r.iso}
                          class=${`${f.isNight ? 'is-night' : ''} ${f.isNow ? 'is-now' : ''}`.trim()}>
                        <td class="utci-time">${timeCell(r)}</td>
                        ${visibleColumnDefs.map(d => {
                          const c = d.render(r, tableRows[rowIdx - 1], tableRows[rowIdx + 1], 'to bottom');
                          return html`<td key=${d.key}
                            class=${`${d.cellClass ?? ''} ${groupStart(d.key)}`.trim()}
                            title=${c.title ?? null}
                            style=${c.style ?? null}>${c.content}</td>`;
                        })}
                      </tr>`;
                  })}
                  </tbody>
                </table>
              </div>
            </div>`;
            })()}
            </div>
            ${forecast && ((glanceSummary && glanceSummary.length > 0) || eventGlanceItems.length > 0 || visible.length > 0) && html`
              <aside class="glance-rail">
                ${((glanceSummary && glanceSummary.length > 0) || eventGlanceItems.length > 0) && html`
                <div class="insight-panel insight-panel--glance">
                  <div class="insight-panel-title">At a glance${glanceDate ? ` · ${glanceDate}` : ''}</div>
                  ${(() => {
                    const heatEvents = eventGlanceItems.filter(e => /heat caution|heat warning|extreme heat/i.test(e.label)).map(e => ({ ...e, alert: true }));
                    const otherEvents = eventGlanceItems.filter(e => !/heat caution|heat warning|extreme heat/i.test(e.label));
                    const renderRow = ({ icon, label, value, sub, alert, grp }, keyPrefix = '') => html`
                      <div class=${`insight-row${alert ? ' insight-row--alert' : ''}${grp ? ` insight-row--${grp}` : ''}`} key=${`${keyPrefix}${label}`}>
                        <span class="insight-icon">${icon}</span>
                        <span class="insight-label">${titleCaseText(label)}</span>
                        <span class="insight-value">${titleCaseText(value)}${sub ? html`<span class="insight-sub">${titleCaseText(sub)}</span>` : ''}</span>
                      </div>`;
                    // Only the outdoors profile reorders items around the heat block
                    // (it has a 'Peak felt temp' anchor). Other profiles render in order.
                    const hasFeltAnchor = glanceSummary.some(i => i.label === 'Peak felt temp');
                    const comfortItem = hasFeltAnchor ? glanceSummary.find(i => i.label === 'Comfortable window') : null;
                    const drivingItem = hasFeltAnchor ? glanceSummary.find(i => i.label === 'Good driving time') : null;
                    const climateRows = glanceSummary.filter(i => i.label === 'Pre-2020 24h Average');
                    const restSummary = glanceSummary.filter(i => i.label !== 'Pre-2020 24h Average' && !(hasFeltAnchor && (i.label === 'Comfortable window' || i.label === 'Good driving time')));
                    return [
                      ...restSummary.flatMap(item => [
                        renderRow(item),
                        // After the peak/heat block, drop in good driving time then the comfortable window
                        ...(item.label === 'Peak felt temp'
                          ? [
                              ...heatEvents.map(e => renderRow(e, 'ev-')),
                              ...(drivingItem ? [renderRow(drivingItem)] : []),
                              ...(comfortItem ? [renderRow(comfortItem)] : []),
                            ]
                          : []),
                      ]),
                      ...climateRows.map(item => renderRow(item)),
                      ...otherEvents.map(e => renderRow(e, 'ev-')),
                    ];
                  })()}
                </div>`}
                ${weekAhead.length > 0 && html`
                  <div class="insight-panel insight-panel--week">
                    <div class="insight-panel-title">The Week Ahead</div>
                    <div class="insight-panel-sub">${titleCaseText(bestDaysLabel(activeProfile, outdoorsVariant))}</div>
                    ${weekAhead.map(({ icon, label, value, score }) => html`
                      <div class="insight-row" key=${label}>
                        <span class="insight-icon">${icon}</span>
                        <span class="insight-label">${titleCaseText(label)}</span>
                        <span class="insight-value">${titleCaseText(value)}</span>
                        ${score != null && html`
                          <span class="week-score" title=${bestDaysHint(activeProfile, outdoorsVariant, score)}>
                            <span class="week-score__track">
                              <span class="week-score__fill" style=${`width:${score}%;background:${scoreFillColor(score)}`}></span>
                            </span>
                            <span class="week-score__pct">${score}%</span>
                          </span>`}
                      </div>`)}
                  </div>`}
                ${visible.length > 0 && html`
                  <div class="export-panel">
                    ${isPro
                      ? html`
                        <button class="export-day-btn" onClick=${handleExportDay} title="Download full hourly data for this day as a spreadsheet">
                          <span class="export-day-btn__icon">⬇</span>
                          Export day data
                        </button>
                        <div class="export-day-hint">Excel / LibreOffice · 41 columns · hourly</div>`
                      : html`
                        <button class="export-day-btn export-day-btn--locked"
                          onClick=${() => { setProPromptSource('export'); setProPromptDay(0); }}
                          title="Export day data is part of SunScope Extra">
                          <span class="export-day-btn__icon">🔒</span>
                          Export day data
                        </button>
                        <div class="export-day-hint">SunScope Extra feature</div>`}
                  </div>`}
              </aside>`}
            </div>

            ${colPopup && COL_DESCRIPTIONS[colPopup.key] && html`
              <div
                ref=${colPopupRef}
                class=${`col-info-popup${colPopup.below ? ' col-info-popup--below' : ''}`}
                style=${{
                  position: 'fixed',
                  left: `${colPopup.x}px`,
                  top: `${colPopup.y}px`,
                  transform: colPopup.below ? 'translateX(-50%)' : 'translateX(-50%) translateY(-100%)',
                  '--arrow-left': `${colPopup.arrowLeft}px`,
                }}
                onMouseEnter=${handlePopupEnter}
                onMouseLeave=${handlePopupLeave}
              >
                <button class="col-info-close" onClick=${closePopup} aria-label="Close">×</button>
                <strong class="col-info-title">${COL_DESCRIPTIONS[colPopup.key].title}</strong>
                <p class="col-info-desc">${COL_DESCRIPTIONS[colPopup.key].short || COL_DESCRIPTIONS[colPopup.key].desc}</p>
                ${COL_DESCRIPTIONS[colPopup.key].link && html`
                  <a class="col-info-more" href=${COL_DESCRIPTIONS[colPopup.key].link} target="_blank" rel="noopener noreferrer">
                    More about this column →
                  </a>`}
              </div>`}

            ${eventTagPopup && (() => {
              const evs = eventTagPopup.events;
              const ev  = evs[evSlideIndex] || evs[0];
              return html`
                <div
                  ref=${eventTagPopupRef}
                  class=${`col-info-popup col-info-popup--ev${eventTagPopup.below ? ' col-info-popup--below' : ''}`}
                  style=${{
                    position: 'fixed',
                    left: `${eventTagPopup.x}px`,
                    top: `${eventTagPopup.y}px`,
                    transform: eventTagPopup.below ? 'translateX(-50%)' : 'translateX(-50%) translateY(-100%)',
                    '--arrow-left': `${eventTagPopup.arrowLeft}px`,
                  }}
                  onMouseEnter=${handleEventTagPopupEnter}
                  onMouseLeave=${handleEventTagPopupLeave}
                >
                  <button class="col-info-close" onClick=${closeEventTagPopup} aria-label="Close">×</button>
                  <div class=${`ev-popup-stage${evTransition ? ` ev-popup-${evTransition}` : ''}`}>
                    <strong class="col-info-title">${ev.emoji} ${ev.title}</strong>
                    <p class="col-info-desc">${ev.message}</p>
                  </div>
                  ${evs.length > 1 && html`
                    <div class="ev-popup-dots">
                      ${evs.map((_, i) => html`
                        <span
                          key=${i}
                          class=${`ev-popup-dot${i === evSlideIndex ? ' active' : ''}`}
                          onClick=${() => evSlideTo(i)}
                        />
                      `)}
                    </div>
                  `}
                </div>`;
            })()}

            <div class="utci-legend">
              <span class="utci-legend-label">Thermal stress bands</span>
              <div class="utci-legend-row">
                ${[
                  { t: -9,  label: 'Freezing',    value: '< 0°C' },
                  { t:  1,  label: 'Cold',         value: '0–10°C' },
                  { t: 11,  label: 'Cool',         value: '10–19°C' },
                  { t: 21,  label: 'Comfortable',  value: '19–24°C', bold: true },
                  { t: 25,  label: 'Warm',         value: '24–27°C' },
                  { t: 29,  label: 'Caution',      value: '27–32°C' },
                  { t: 36,  label: 'Extreme',      value: '32–41°C' },
                  { t: 47,  label: 'Danger',       value: '41°C+' },
                ].map((b, i) => {
                  const rgb = airTempRgb(b.t) || [200, 200, 200];
                  const mid = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
                  const light = `rgb(${rgb.map(c => Math.min(255, Math.round(c + (255-c)*0.30))).join(',')})`;
                  const dark  = `rgb(${rgb.map(c => Math.round(c * 0.96)).join(',')})`;
                  const bg = `linear-gradient(135deg, ${light} 0%, ${mid} 60%, ${dark} 100%)`;
                  const lum = (rgb[0]*299 + rgb[1]*587 + rgb[2]*114) / 1000;
                  const fg = lum > 165 ? '#1a1a1a' : '#ffffff';
                  return html`<span key=${i} class="utci-legend-item" style=${{ background: bg, color: fg, ...(b.bold ? { fontWeight: 700 } : {}) }}>
                    <span class="utci-legend-item-label">${b.label}</span>
                    <span class="utci-legend-item-value">${b.value}</span>
                  </span>`;
                })}
              </div>

              ${(visibleCols.furSurfaceT || visibleCols.petHomeT || visibleCols.petShadeT || visibleCols.pawT) && html`
                <span class="utci-legend-label utci-legend-label--secondary">Pet thermal stress bands</span>
                <div class="utci-legend-row">
                  ${[
                    { t: -15, label: 'Freezing',    value: '< -8°C' },
                    { t:   0, label: 'Cold',         value: '-8–2°C' },
                    { t:   9, label: 'Cool',         value: '2–11°C' },
                    { t:  18, label: 'Comfortable',  value: '11–25°C', bold: true },
                    { t:  28, label: 'Warm',         value: '25–32°C' },
                    { t:  36, label: 'Caution',      value: '32–40°C' },
                    { t:  46, label: 'Extreme',      value: '40–52°C' },
                    { t:  60, label: 'Danger',       value: '52°C+' },
                  ].map((b, i) => {
                    // Exact same recipe as the human legend above (same 135deg
                    // light/mid/dark sweep, same luminance-based font colour) -
                    // just reading from petAirTempRgb instead of airTempRgb.
                    const rgb = petAirTempRgb(b.t) || [200, 200, 200];
                    const mid = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
                    const light = `rgb(${rgb.map(c => Math.min(255, Math.round(c + (255-c)*0.30))).join(',')})`;
                    const dark  = `rgb(${rgb.map(c => Math.round(c * 0.96)).join(',')})`;
                    const bg = `linear-gradient(135deg, ${light} 0%, ${mid} 60%, ${dark} 100%)`;
                    const lum = (rgb[0]*299 + rgb[1]*587 + rgb[2]*114) / 1000;
                    const fg = lum > 165 ? '#1a1a1a' : '#ffffff';
                    return html`<span key=${i} class="utci-legend-item" style=${{ background: bg, color: fg, ...(b.bold ? { fontWeight: 700 } : {}) }}>
                      <span class="utci-legend-item-label">${b.label}</span>
                      <span class="utci-legend-item-value">${b.value}</span>
                    </span>`;
                  })}
                </div>`}
            </div>


            ${(() => {
              const upcoming = getUpcomingEvents(location, 3650).slice(0, 4);
              const formatPeak = (iso) => {
                const d = new Date(iso + 'T00:00Z');
                return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' });
              };
              const countdownLabel = (days) => {
                if (days === 0) return 'Tonight!';
                if (days === 1) return 'Tomorrow';
                if (days < 7)  return `In ${days} days`;
                if (days < 14) return 'Next week';
                if (days < 60) return `In ${Math.round(days / 7)} weeks`;
                return `In ${Math.round(days / 30)} months`;
              };
              return html`
                <div class="almanac-panel">
                  <div class="almanac-header">
                    <span class="almanac-title">🔭 What's Coming</span>
                    <span class="almanac-subtitle">Cosmic events · next 4 events · ${location.name}</span>
                  </div>
                  <div class="almanac-list">
                    ${upcoming.length === 0
                      ? html`<div class="almanac-empty">No major cosmic events on the horizon — clear skies ahead.</div>`
                      : upcoming.map(ev => html`
                        <div key=${ev.id} class="almanac-entry">
                          <div class="almanac-entry-accent" style=${{ background: ev.color === '#fdf8ee' ? '#c8922a' : ev.color }} />
                          <div class="almanac-entry-icon">${ev.emoji}</div>
                          <div class="almanac-entry-body">
                            <div class="almanac-entry-head">
                              <span class="almanac-entry-title">${ev.title}</span>
                              <span class="almanac-entry-date">${formatPeak(ev.peak)}</span>
                              <span class="almanac-entry-countdown">${countdownLabel(ev.daysUntil)}</span>
                            </div>
                            <div class="almanac-entry-desc">${ev.desc}</div>
                            ${ev.visibilityNote && html`
                              <span class="almanac-entry-visibility">📍 ${ev.visibilityNote}</span>
                            `}
                          </div>
                        </div>
                      `)
                    }
                  </div>
                </div>`;
            })()}

        <div class="utci-bottom-grid">
          <div class="utci-about">
            <h2 class="utci-about-heading">What is SunScope?</h2>
            <p class="utci-about-text">
              SunScope is a free hourly weather forecast built around <strong>felt temperature</strong>,
              not just air temperature. It uses the <strong>Universal Thermal Climate Index (UTCI)</strong>
              — the biometeorological standard used in heat-health warning systems worldwide — as its
              foundation, combining air temperature, humidity, wind, and solar radiation into a single
              honest number. Then it goes further. Our <strong>SunSoak</strong> index layers three extra
              dimensions on top: a rain and snow penalty so wet, windy days read as cold as they feel;
              an <strong>environment modifier</strong> that adjusts the solar load for where you actually
              are — forest canopy, alpine altitude, lakeside glare, shaded riverbank, desert ground heat;
              and the full radiant heat absorbed from surrounding surfaces. One number that honestly
              answers: <em>what will my body actually feel out there?</em>
            </p>
            <p class="utci-about-text">
              Beyond SunSoak, SunScope calculates <strong>vehicle cabin heat</strong> (choose your
              vehicle type; toggle windows open), <strong>indoor temperature</strong> (seven building
              types including office blocks; managed heatwave mode), <strong>urban concrete surface
              temperature</strong>, <strong>UV index and sunburn time</strong> by skin type, and
              <strong>soil temperature and moisture</strong> for farming and motorhome use. Switch
              profiles — Places, Activities, Work — to see the data that matters for your situation,
              or go Custom and build your own view.
              <a href="./about.html" class="utci-about-link">Learn more →</a>
            </p>
          </div>
          <div class="utci-reading-box">
            <h2 class="utci-about-heading">Reading the table</h2>
            <p class="utci-about-text">
              SunSoak is the number to watch — it reflects everything your body
              actually experiences, not just what the thermometer says. A large gap between SunSoak and Air
              temperature means solar radiation is doing significant work on your body. On clear sunny days
              that gap can exceed 10 °C even at modest air temperatures. The environment modifier in the
              SunSoak dropdown adjusts the solar load for your surroundings — switch it to match where you are
              for the most accurate reading.
              ${' '}<a onClick=${openWelcome}
                 style=${{ color: '#9a7d5a', borderBottom: '1px solid rgba(154,125,90,0.4)', paddingBottom: '1px', textDecoration: 'none', cursor: 'pointer' }}>
                How it works →
              </a>
            </p>
          </div>
        </div>

        ${isPro && html`
          <div class="utci-footer">
            <div style=${{ marginTop: '10px', paddingTop: '10px', borderTop: '1px solid #d4c0a0', textAlign: 'center' }}>
              SunScope Extra is active.
              <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00"
                 target="_blank" rel="noopener noreferrer"
                 style=${{ color: '#9a7d5a', borderBottom: '1px solid rgba(154,125,90,0.4)', paddingBottom: '1px', textDecoration: 'none' }}>
                Manage or cancel subscription →
              </a>
            </div>
          </div>
        `}

      <footer class="utci-site-footer">
        © 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a>
        · <a href="./index.html">Forecast</a>
        · <a href="./about.html">About</a>
        · <a href="./faq.html">FAQ</a>
        · <a href=${isPro
            ? 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'
            : 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'}
           target="_blank" rel="noopener noreferrer">Account</a>
        · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a>
        · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
      </footer>
      </main>
    </div>`;
}
