diff --git a/assets/sunscope.css b/assets/sunscope.css
index 38df579..14c0b78 100644
--- a/assets/sunscope.css
+++ b/assets/sunscope.css
@@ -82,7 +82,7 @@ body {
color: #1e1208;
background: #f5edd6;
min-height: 100vh;
- padding: 52px 28px 32px;
+ padding: 52px 10px 32px;
position: relative;
/* overflow-x: clip (not hidden) — `clip` clips horizontal overflow
WITHOUT making this a scroll container, so `position: sticky` on
@@ -564,6 +564,12 @@ body {
width: 100%;
border-collapse: collapse;
font-size: 14px;
+ /* Fixed layout so the explicit per-cell widths set by the JS width-sync
+ (see useLayoutEffect in sunscope.js) are actually enforced. Without
+ this the browser's auto layout treats those widths as suggestions and
+ redistributes space, which makes the header and body columns drift
+ apart the further right you scroll. */
+ table-layout: fixed;
}
/* Column headings (Hour, Air, RH, Wind...).
@@ -627,29 +633,40 @@ body {
border-right: 1px solid #ede4cc;
}
+/* The Dir column is centered on both desktop and mobile so the
+ compass vane sits with even padding either side — especially
+ important on mobile where the compass label is hidden and there's
+ no text to balance the right-aligned content. */
+.utci-table th.utci-dir-cell,
+.utci-table td.utci-dir-cell {
+ text-align: center;
+}
+
+/* "Tight" header — for columns whose body content is narrower than
+ the default tracked-out header would suggest (Air, Pcpt). Reducing
+ the letter-spacing shrinks the heading's natural width so it stops
+ driving the column wider than the body needs. Right-aligned values
+ then sit with balanced visual padding on either side. */
+.utci-table th.utci-tight-head {
+ letter-spacing: 0.04em;
+}
+
/* ── 8. NOW-ROW & NIGHT-ROW DECORATIONS ─────────────────────────────── */
-/* "Currently this hour" — the highlighted row */
+/* "Currently this hour" — the highlighted row.
+ The row gets a cream-yellow background and darker text. No left
+ stripe or pip — the colour shift alone signals "this is now". */
.utci-table tbody tr.is-now {
background: #fff8e4 !important;
- box-shadow: inset 3px 0 #c8922a; /* brass stripe down the left */
}
.utci-table tbody tr.is-now td {
color: #1e1208;
-}
-
-/* The little brass pip in the time cell of the current hour */
-.now-pip {
- display: inline-block;
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: #c8922a;
- margin-right: 7px;
- vertical-align: middle;
- box-shadow: 0 0 8px #c8922a73;
+ /* Brass bracket framing the current-hour row top and bottom — clean,
+ definitive, no left stripe needed. */
+ border-top: 2px solid #c8922a;
+ border-bottom: 2px solid #c8922a;
}
/* Night-time hours get a slightly muted text colour */
@@ -843,4 +860,19 @@ body {
margin: 0 auto;
}
+ /* Halve the hourly-table cell padding on mobile so more columns fit
+ in the viewport before the horizontal scrollbar kicks in. */
+ .utci-table th {
+ padding: 7px 6px;
+ }
+ .utci-table td {
+ padding: 5px 6px;
+ }
+
+ /* Drop the compass-label text ("N", "NW"...) next to the wind vane on
+ mobile — the vane arrow alone is enough at small sizes. */
+ .wind-dir-label {
+ display: none;
+ }
+
}
diff --git a/assets/sunscope.js b/assets/sunscope.js
index 54a84ce..06eb12f 100644
--- a/assets/sunscope.js
+++ b/assets/sunscope.js
@@ -854,12 +854,12 @@ function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date() }) {
font-family="Fraunces, serif" font-weight="700">
${value.toFixed(1)}°
-
UTCI NOW
-
${cat.label.toUpperCase()}
@@ -974,8 +974,8 @@ function UTCIForecast() {
// true = visible on first load (and the only ones free users see)
// false = hidden by default (Pro users can toggle these on)
const [visibleCols, setVisibleCols] = useState({
- hour: true, air: true, rh: true, dew: true,
- wind: true, dir: true,
+ hour: true, air: true, rh: false, dew: false,
+ wind: true, dir: false,
cloud: false, sun: false, direct: false, diffuse: false,
tmrt: false, delta: false, utci: false,
uvA: false, uvB: false, burn: false,
@@ -1015,47 +1015,84 @@ function UTCIForecast() {
};
useLayoutEffect(() => {
- // Synchronise the head and body table column widths.
- // Each column is sized to max(60px, header's natural width, body's
- // natural width) so neither header labels nor body data ever feel
- // squashed, and header text never overflows into the next column.
- const MIN_COL = 60;
+ // Synchronise the head and body table column widths with a
+ // "shrink-to-fit then distribute" strategy:
+ // • Measure each column's true natural (content-fit) width by
+ // temporarily switching both tables to table-layout: auto +
+ // width: max-content. White-space: nowrap on cells stops content
+ // from wrapping, so the measurement is the smallest width that
+ // won't clip the content.
+ // • If the body scroller has spare horizontal space (natural total
+ // < container width), scale every column up proportionally to
+ // fill it — so toggling columns off makes the remaining ones fan
+ // out instead of leaving an awkward gap.
+ // • Otherwise apply the natural widths as-is and let the body
+ // scroller's overflow-x: auto produce a horizontal scrollbar.
const sync = () => {
- const headTable = headTableRef.current;
- const bodyTable = bodyTableRef.current;
- if (!headTable || !bodyTable) return;
+ const headTable = headTableRef.current;
+ const bodyTable = bodyTableRef.current;
+ const bodyScroll = bodyScrollRef.current;
+ if (!headTable || !bodyTable || !bodyScroll) return;
const bodyRow = bodyTable.querySelector('tbody tr');
const headRow = headTable.querySelector('thead tr');
if (!bodyRow || !headRow) return;
const headCells = Array.from(headRow.children);
const bodyCells = Array.from(bodyRow.children);
const n = Math.min(headCells.length, bodyCells.length);
- // Step 1: clear any previously-forced widths so we can read each
- // cell's *natural* width (the width it'd take with just min-width
- // and content driving it).
- headCells.forEach(c => {
- c.style.width = '';
- c.style.minWidth = '';
- c.style.maxWidth = '';
- });
- bodyCells.forEach(c => {
- c.style.width = '';
- c.style.minWidth = '';
- c.style.maxWidth = '';
- });
- headTable.style.width = '';
- // Reading getBoundingClientRect forces layout — that's what we want.
- // Step 2: compute final widths as max(MIN_COL, headNatural, bodyNatural).
- const finalW = new Array(n);
- let totalWidth = 0;
+ if (n === 0) return;
+
+ // Step 1: clear any previously-forced cell widths and switch the
+ // tables to natural sizing so the measurement reflects the true
+ // content-fit width — independent of how wide the container is.
+ headCells.forEach(c => { c.style.width = ''; c.style.minWidth = ''; c.style.maxWidth = ''; });
+ bodyCells.forEach(c => { c.style.width = ''; c.style.minWidth = ''; c.style.maxWidth = ''; });
+ headTable.style.width = 'max-content';
+ bodyTable.style.width = 'max-content';
+ headTable.style.tableLayout = 'auto';
+ bodyTable.style.tableLayout = 'auto';
+
+ // Step 2: read each cell's natural width. getBoundingClientRect
+ // forces synchronous layout — that's what we want.
+ const naturalW = new Array(n);
+ let naturalTotal = 0;
for (let i = 0; i < n; i++) {
const headW = headCells[i].getBoundingClientRect().width;
const bodyW = bodyCells[i].getBoundingClientRect().width;
- const w = Math.max(MIN_COL, Math.ceil(headW), Math.ceil(bodyW));
- finalW[i] = w;
- totalWidth += w;
+ const w = Math.max(Math.ceil(headW), Math.ceil(bodyW));
+ naturalW[i] = w;
+ naturalTotal += w;
}
- // Step 3: apply the final width to both head and body cells.
+
+ // Step 3: decide final widths based on available container width.
+ const containerW = bodyScroll.clientWidth;
+ const finalW = new Array(n);
+ let totalWidth;
+ if (naturalTotal > 0 && naturalTotal < containerW) {
+ // Spare space — distribute proportionally across columns so they
+ // fan out to fill the scroller (no awkward right-hand gap).
+ const scale = containerW / naturalTotal;
+ let running = 0;
+ for (let i = 0; i < n - 1; i++) {
+ finalW[i] = Math.floor(naturalW[i] * scale);
+ running += finalW[i];
+ }
+ // Absorb sub-pixel rounding into the last column so the total
+ // exactly matches the container width.
+ finalW[n - 1] = containerW - running;
+ totalWidth = containerW;
+ } else {
+ // Naturals don't fit — use them as-is and let the body scroll.
+ for (let i = 0; i < n; i++) finalW[i] = naturalW[i];
+ totalWidth = naturalTotal;
+ }
+
+ // Step 4: restore the CSS-defined table-layout: fixed so the
+ // explicit cell widths we apply below are honoured by the browser
+ // (not redistributed by the auto-layout algorithm).
+ headTable.style.tableLayout = '';
+ bodyTable.style.tableLayout = '';
+
+ // Step 5: apply the final width to both head and body cells.
for (let i = 0; i < n; i++) {
const px = `${finalW[i]}px`;
headCells[i].style.width = px;
@@ -1065,19 +1102,24 @@ function UTCIForecast() {
bodyCells[i].style.minWidth = px;
bodyCells[i].style.maxWidth = px;
}
- // Make the header table the same total width as the body table
- // so the inner track has somewhere to translate to.
+ // Make both tables exactly totalWidth wide so they share the same
+ // horizontal extent — column N in the header sits directly above
+ // column N in the body, no drift as you scroll right.
headTable.style.width = `${totalWidth}px`;
+ bodyTable.style.width = `${totalWidth}px`;
// Re-apply current horizontal offset so column alignment survives.
handleBodyScroll();
};
// Run once after layout
sync();
- // Re-sync if the body table reflows (columns toggle, content changes)
+ // Re-sync when the scroll container's width changes (window resize,
+ // sidebar opens, etc). We observe the scroller — not the body table —
+ // because the body table's width is now driven by sync itself, which
+ // would otherwise create a feedback loop.
let ro = null;
- if (typeof ResizeObserver !== 'undefined' && bodyTableRef.current) {
+ if (typeof ResizeObserver !== 'undefined' && bodyScrollRef.current) {
ro = new ResizeObserver(sync);
- ro.observe(bodyTableRef.current);
+ ro.observe(bodyScrollRef.current);
}
window.addEventListener('resize', sync);
return () => {
@@ -1500,7 +1542,7 @@ function UTCIForecast() {