New profile tabs
Moved Solar model to Why box
This commit is contained in:
fraxle
2026-06-05 03:46:16 +01:00
parent 714eb109fd
commit f9b5c2b248
7 changed files with 240 additions and 158 deletions
+18 -8
View File
@@ -7,21 +7,15 @@
.utci-header {
display: grid;
grid-template-columns: 1fr auto 1fr; /* left | dial | why */
grid-template-areas:
"hleft dial hright"
". env .";
grid-template-areas: "hleft dial hright";
column-gap: 32px;
row-gap: 6px;
align-items: center;
padding-bottom: 6px;
margin-bottom: 6px;
}
/* Side columns occupy only the dial's row, so they vertically centre against
the dial itself; the environment selector drops to its own row beneath. */
.header-left { grid-area: hleft; }
.scope-col { grid-area: dial; }
.scope-env-bar { grid-area: env; }
.header-right { grid-area: hright; }
/* Left column — wordmark, tagline, search and location stacked together */
@@ -276,10 +270,15 @@
}
.scope-env-row .cs-panel,
.scope-env-row .cs-option {
.scope-env-row .cs-option,
.insight-env-footer .cs-panel,
.insight-env-footer .cs-option {
text-align: center;
}
.insight-env-footer .cs-btn.on:hover .cs-btn-overlay { opacity: 1; }
.insight-env-footer .cs-btn .cs-btn-label { position: relative; z-index: 1; }
/* Per-environment gradient themes — !important keeps the gradient fixed
under the brass hover overlay (ui.css :hover rules load later). */
.scope-env-row[data-env="open"] .cs-btn.on { background: linear-gradient(135deg, #5a8a28 0%, #b8d860 100%) !important; border-color: #4a7818 !important; color: #fff; }
@@ -291,8 +290,19 @@
.scope-env-row[data-env="openwater"] .cs-btn.on { background: linear-gradient(135deg, #0c4878 0%, #2898d8 100%) !important; border-color: #0c4878 !important; color: #fff; }
.scope-env-row[data-env="urban"] .cs-btn.on { background: linear-gradient(135deg, #4a5568 0%, #a89880 100%) !important; border-color: #3a4455 !important; color: #fff; }
/* Duplicate gradient rules for the Solar model button inside the insight panel */
.insight-env-footer[data-env="open"] .cs-btn.on { background: linear-gradient(135deg, #5a8a28 0%, #b8d860 100%) !important; border-color: #4a7818 !important; color: #fff; }
.insight-env-footer[data-env="forest"] .cs-btn.on { background: linear-gradient(135deg, #1b5e3b 0%, #57bf8a 100%) !important; border-color: #1b5e3b !important; color: #fff; }
.insight-env-footer[data-env="alpine"] .cs-btn.on { background: linear-gradient(135deg, #a8cfe8 0%, #dff0fa 100%) !important; border-color: #6aaedd !important; color: #1a3a58; }
.insight-env-footer[data-env="beach"] .cs-btn.on { background: linear-gradient(135deg, #2a8fc8 0%, #e8d87a 100%) !important; border-color: #1e78b0 !important; color: #fff; }
.insight-env-footer[data-env="river"] .cs-btn.on { background: linear-gradient(135deg, #2e6e5e 0%, #78c4a8 100%) !important; border-color: #2e6e5e !important; color: #fff; }
.insight-env-footer[data-env="desert"] .cs-btn.on { background: linear-gradient(135deg, #b86820 0%, #e8c458 100%) !important; border-color: #a05818 !important; color: #fff; }
.insight-env-footer[data-env="openwater"] .cs-btn.on { background: linear-gradient(135deg, #0c4878 0%, #2898d8 100%) !important; border-color: #0c4878 !important; color: #fff; }
.insight-env-footer[data-env="urban"] .cs-btn.on { background: linear-gradient(135deg, #4a5568 0%, #a89880 100%) !important; border-color: #3a4455 !important; color: #fff; }
/* On hover the brass overlay covers the interior — force white text for contrast */
.scope-env-row .cs-btn.on:hover { color: #fff; }
.insight-env-footer .cs-btn.on:hover { color: #fff; }
/* The search box on the right of the header */
.utci-search-wrap {
+51 -4
View File
@@ -11,7 +11,7 @@
/* Wrapper that hosts the scrollable strip, edge fades, and chevrons. */
.utci-day-tabs-wrap {
position: relative;
margin-top: 14px;
margin-top: 12px;
}
/* Edge fade gradients — only show when there's content to scroll to. */
@@ -132,7 +132,7 @@
flex-direction: column;
gap: 0;
margin-bottom: 0;
padding: 0 14px 0;
padding: 0px;
background: transparent;
border: none;
}
@@ -160,8 +160,8 @@
justify-content: safe center; /* fall back to flex-start if it overflows */
overflow-x: auto;
overflow-y: auto; /* forced by browser when overflow-x is non-visible */
padding-bottom: 400px; /* extends clip boundary so the tallest dropdown (Activities) isn't cut off */
margin-bottom: -400px; /* cancel that extra space in the layout */
padding-bottom: 6px;
margin-bottom: 0;
scrollbar-width: none;
-ms-overflow-style: none;
padding-right: 4px; /* breathing room at right edge */
@@ -173,6 +173,12 @@
.profile-scroll-wrap {
position: relative;
width: 100%;
padding-top:8px;
padding-bottom:4px;
background-color: #fffcf2;
border-left: 1.5px solid #c9b08a;
border-right: 1.5px solid #c9b08a;
border-bottom: 1.5px solid #c9b08a;
}
.profile-scroll-wrap::before,
@@ -285,6 +291,47 @@
color: #fff;
}
/* Tab strip above the profile scroll row */
.profile-tabs {
display: flex;
width: 100%;
border-bottom: 1.5px solid #c9b08a;
margin-bottom: 0px;
}
.profile-tab {
flex: 1;
padding: 7px 4px;
background: transparent;
border: none;
border-bottom: 2.5px solid transparent;
border-left: 1.5px solid #f5edd6;
border-right: 1.5px solid #f5edd6;
border-top: 1.5px solid #f5edd6;
margin-bottom: -1.5px;
font-family: Manrope, sans-serif;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #9a7a4a;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.profile-tab:hover {
color: #c8922a;
background: #f0e4c4;
}
.profile-tab.active {
color: #c8922a;
background-color: #fffcf2;
border-bottom-color: #fffcf2;
border-left: 1.5px solid #c9b08a;
border-right: 1.5px solid #c9b08a;
border-top: 1.5px solid #c9b08a;
border-radius:7px 7px 0px 0px
}
/* ── 6. COLUMN TOGGLES (the "Columns: Hour Air RH ..." bar) ────────── */
+54 -1
View File
@@ -1176,12 +1176,64 @@
Sits between the header dial and the day tabs.
════════════════════════════════════════════════════════════════════════ */
/* "Why it feels like this" sits in the header's right column, beside the dial */
/* "Why it feels like this" - sits in the header's right column, beside the dial */
.insight-panel--why {
width: 100%;
max-width: 300px;
}
/* Solar model selector inside the Why it feels like panel */
.insight-env-footer {
display: flex;
flex-direction: column;
align-items: stretch;
border-bottom: 1px solid #ede4cc;
padding-bottom: 8px;
margin-bottom: 6px;
gap: 4px;
}
.insight-env-footer-label {
font-family: Manrope, sans-serif;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #b09870;
text-align: center;
}
.insight-env-footer .scope-env-row {
width: 100%;
border-top: none;
padding: 0;
align-items: stretch;
}
.insight-env-footer .cs-wrap,
.insight-env-footer .cs-btn {
width: 100%;
}
.insight-env-footer .cs-btn {
justify-content: center;
position: relative;
isolation: isolate;
border-radius: 4px;
}
.insight-env-footer .cs-btn .cs-btn-overlay {
display: block;
position: absolute;
inset: 0;
border-radius: inherit;
background: #c8922a;
opacity: 0;
transition: opacity 0.35s ease;
pointer-events: none;
z-index: 0;
}
.insight-env-footer .cs-arrow {
position: absolute;
right: 12px;
z-index: 1;
}
/* Hourly table paired with the "Today at a glance" rail on its right */
.table-with-rail {
display: grid;
@@ -1222,6 +1274,7 @@
letter-spacing: 0.1em;
color: #b09870;
margin-bottom: 10px;
text-align: center;
}
/* Each factor row */
+14 -17
View File
@@ -441,26 +441,23 @@ export function UTCIForecast() {
/>
</div>
<div class="scope-env-bar">
<div class="scope-env-row" data-env=${utciEnv}>
<span class="scope-env-label">Environment</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>
<div class="header-right">
${forecast && currentRow && 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 like this</div>
${[
{ label: 'Sun and sky', icon: '☀', value: whyFeelsLike.sunAndSky },
+98 -125
View File
@@ -42,7 +42,7 @@
// ------------------------------------------------------------------------
import { h, Fragment } from '../../vendor/preact.js';
import { useRef, useEffect } from '../../vendor/preact-hooks.js';
import { useRef, useEffect, useState } from '../../vendor/preact-hooks.js';
import htm from '../../vendor/htm.js';
import { confidenceBand } from '../utils.js';
import { FREE_DAYS, FILTER_PROFILES, OUTDOORS_VARIANTS, profileButtonOrder, activityVariantKeys, placeVariantKeys, workVariantKeys } from '../config.js';
@@ -96,6 +96,17 @@ export function DayTabs({
const profileScrollRef = useRef(null);
const profileWrapRef = useRef(null);
// Active tab: common - places - activities - work.
// Auto-derived from the current profile on mount and on change.
const getTab = () => {
if (activeProfile === 'outdoors' && placeVariantKeys.includes(outdoorsVariant)) return 'places';
if (activeProfile === 'outdoors' && activityVariantKeys.includes(outdoorsVariant)) return 'activities';
if (activeProfile === 'farming' || (activeProfile === 'outdoors' && workVariantKeys.includes(outdoorsVariant))) return 'work';
return 'common';
};
const [activeTab, setActiveTab] = useState(getTab);
useEffect(() => { setActiveTab(getTab()); }, [activeProfile, outdoorsVariant]);
useEffect(() => {
const el = profileScrollRef.current;
const wrap = profileWrapRef.current;
@@ -155,139 +166,98 @@ export function DayTabs({
<${Fragment}>
<div class="filter-profiles">
<div class="profile-tabs">
${[['common','Common'],['places','Places'],['activities','Activities'],['work','Work']].map(([key, label]) => html`
<button
key=${key}
class=${`profile-tab${activeTab === key ? ' active' : ''}`}
onClick=${() => setActiveTab(key)}
>${label}</button>
`)}
</div>
<div class="profile-scroll-wrap" ref=${profileWrapRef}>
<div class="profile-scroll" ref=${profileScrollRef}>
${profileButtonOrder.slice(0, 3).map((key) => {
const profile = FILTER_PROFILES[key];
const locked = profile.proOnly && !isPro;
return html`
${activeTab === 'common' && profileButtonOrder.map((key) => {
const profile = FILTER_PROFILES[key];
const locked = profile.proOnly && !isPro;
return html`
<button
key=${key}
class=${`profile-btn${activeProfile === key ? ' active' : ''}${locked ? ' locked' : ''}`}
style=${{ opacity: locked ? 0.6 : 1, cursor: locked ? 'not-allowed' : 'pointer', position: 'relative' }}
title=${locked ? `Profile ${profile.label} is part of SunScope Extra` : ''}
onClick=${() => {
if (locked) { setProPromptSource(`profile:${key}`); setProPromptDay(0); return; }
activateProfile(key);
}}
>
<span class="profile-btn-scene" style=${{ background: profile.scene }}>${profile.scene.includes('url(') ? null : profile.icon}</span>
<span class="profile-btn-label">${profile.label}${locked ? ' locked' : ''}</span>
</button>`;
})}
${activeTab === 'places' && placeOptions.map((opt) => html`
<button
key=${key}
class=${`profile-btn${activeProfile === key ? ' active' : ''}${locked ? ' locked' : ''}`}
style=${{ opacity: locked ? 0.6 : 1, cursor: locked ? 'not-allowed' : 'pointer', position: 'relative' }}
title=${locked ? `🔒 ${profile.label} is part of SunScope Extra` : ''}
key=${opt.value}
class=${`profile-btn${placeValue === opt.value ? ' active' : ''}${opt.disabled ? ' locked' : ''}`}
style=${{ opacity: opt.disabled ? 0.6 : 1, cursor: opt.disabled ? 'not-allowed' : 'pointer', position: 'relative' }}
onClick=${() => {
if (locked) {
setProPromptSource(`profile:${key}`);
setProPromptDay(0);
return;
}
activateProfile(key);
if (opt.disabled) { setProPromptSource(`variant:${opt.value}`); setProPromptDay(0); return; }
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch(e) {}
setActiveProfile('outdoors');
setOutdoorsVariantAndSave(opt.value);
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
setIndoorMode('off');
setIndoorManaged(false);
}}
>
<span class="profile-btn-scene" style=${{ background: profile.scene }}>${profile.scene.includes('url(') ? null : profile.icon}</span>
<span class="profile-btn-label">${profile.label}${locked ? ' 🔒' : ''}</span>
</button>`;
})}
<span class="profile-divider" aria-hidden="true"></span>
<${CustomSelect}
key="places"
value=${placeValue}
isOn=${activeProfile === 'outdoors' && placeVariantKeys.includes(outdoorsVariant)}
noHide=${true}
hideLabel="Places"
buttonLabel=${placeValue === 'off' ? 'Places' : `${placeLabel}`}
grpClass="profile-card"
sceneStyle=${{ background: placeValue !== 'off' && OUTDOORS_VARIANTS[placeValue]?.scene ? OUTDOORS_VARIANTS[placeValue].scene : "url('assets/images/profiles/places.png') center / cover no-repeat, linear-gradient(160deg,#98ccc0,#c4e4dc)" }}
sceneContent=${null}
options=${placeOptions}
onChange=${(v) => {
if (v === 'off') { activateProfile('basic'); return; }
if (OUTDOORS_VARIANTS[v]?.proOnly && !isPro) {
setProPromptSource(`variant:${v}`);
setProPromptDay(0);
return;
}
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch (e) { /* ignore */ }
setActiveProfile('outdoors');
setOutdoorsVariantAndSave(v);
setVisibleCols({ ...OUTDOORS_VARIANTS[v].cols });
setIndoorMode('off');
setIndoorManaged(false);
}}
/>
<${CustomSelect}
key="activities"
value=${activityValue}
isOn=${activeProfile === 'outdoors' && activityVariantKeys.includes(outdoorsVariant)}
noHide=${true}
hideLabel="Activities"
buttonLabel=${activityValue === 'off' ? 'Activities' : `${activityLabel}`}
grpClass="profile-card"
sceneStyle=${{ background: activityValue !== 'off' && OUTDOORS_VARIANTS[activityValue]?.scene ? OUTDOORS_VARIANTS[activityValue].scene : "url('assets/images/profiles/activities.png') center / cover no-repeat, linear-gradient(160deg,#e0c0a0,#f0d8c0)" }}
sceneContent=${null}
options=${activityOptions}
onChange=${(v) => {
if (v === 'off') { activateProfile('basic'); return; }
if (OUTDOORS_VARIANTS[v]?.proOnly && !isPro) {
setProPromptSource(`variant:${v}`);
setProPromptDay(0);
return;
}
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch (e) { /* ignore */ }
setActiveProfile('outdoors');
setOutdoorsVariantAndSave(v);
setVisibleCols({ ...OUTDOORS_VARIANTS[v].cols });
setIndoorMode('off');
setIndoorManaged(false);
}}
/>
<${CustomSelect}
key="work"
value=${workValue}
isOn=${activeProfile === 'farming' || (activeProfile === 'outdoors' && workVariantKeys.includes(outdoorsVariant))}
noHide=${true}
hideLabel="Work"
buttonLabel=${workValue === 'off' ? 'Work' : `${workLabel}`}
grpClass="profile-card"
sceneStyle=${{ background: workValue === 'farming' ? FILTER_PROFILES.farming.scene : workValue !== 'off' && OUTDOORS_VARIANTS[workValue]?.scene ? OUTDOORS_VARIANTS[workValue].scene : "url('assets/images/profiles/work.png') center / cover no-repeat, linear-gradient(160deg,#b0b0d4,#d0d0e8)" }}
sceneContent=${null}
options=${workOptions}
onChange=${(v) => {
if (v === 'off') { activateProfile('basic'); return; }
if (v === 'farming') { activateProfile('farming'); return; }
if (OUTDOORS_VARIANTS[v]?.proOnly && !isPro) {
setProPromptSource(`variant:${v}`);
setProPromptDay(0);
return;
}
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch (e) { /* ignore */ }
setActiveProfile('outdoors');
setOutdoorsVariantAndSave(v);
setVisibleCols({ ...OUTDOORS_VARIANTS[v].cols });
if (v === 'office') {
setBuildingType('office');
setIndoorMode('on');
setIndoorManaged(false);
} else {
setIndoorMode('off');
setIndoorManaged(false);
}
}}
/>
<span class="profile-divider" aria-hidden="true"></span>
${profileButtonOrder.slice(3).map((key) => {
const profile = FILTER_PROFILES[key];
const locked = profile.proOnly && !isPro;
return html`
<span class="profile-btn-scene" style=${{ background: opt.scene }}></span>
<span class="profile-btn-label">${opt.name}</span>
</button>
`)}
${activeTab === 'activities' && activityOptions.map((opt) => html`
<button
key=${key}
class=${`profile-btn${activeProfile === key ? ' active' : ''}${locked ? ' locked' : ''}`}
style=${{ opacity: locked ? 0.6 : 1, cursor: locked ? 'not-allowed' : 'pointer', position: 'relative' }}
title=${locked ? `🔒 ${profile.label} is part of SunScope Extra` : ''}
key=${opt.value}
class=${`profile-btn${activityValue === opt.value ? ' active' : ''}${opt.disabled ? ' locked' : ''}`}
style=${{ opacity: opt.disabled ? 0.6 : 1, cursor: opt.disabled ? 'not-allowed' : 'pointer', position: 'relative' }}
onClick=${() => {
if (locked) {
setProPromptSource(`profile:${key}`);
setProPromptDay(0);
return;
}
activateProfile(key);
if (opt.disabled) { setProPromptSource(`variant:${opt.value}`); setProPromptDay(0); return; }
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch(e) {}
setActiveProfile('outdoors');
setOutdoorsVariantAndSave(opt.value);
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
setIndoorMode('off');
setIndoorManaged(false);
}}
>
<span class="profile-btn-scene" style=${{ background: profile.scene }}>${profile.scene.includes('url(') ? null : profile.icon}</span>
<span class="profile-btn-label">${profile.label}${locked ? ' 🔒' : ''}</span>
</button>`;
})}
<span class="profile-btn-scene" style=${{ background: opt.scene }}></span>
<span class="profile-btn-label">${opt.name}</span>
</button>
`)}
${activeTab === 'work' && workOptions.map((opt) => html`
<button
key=${opt.value}
class=${`profile-btn${workValue === opt.value ? ' active' : ''}${opt.disabled ? ' locked' : ''}`}
style=${{ opacity: opt.disabled ? 0.6 : 1, cursor: opt.disabled ? 'not-allowed' : 'pointer', position: 'relative' }}
onClick=${() => {
if (opt.disabled) { setProPromptSource(`variant:${opt.value}`); setProPromptDay(0); return; }
if (opt.value === 'farming') { activateProfile('farming'); return; }
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch(e) {}
setActiveProfile('outdoors');
setOutdoorsVariantAndSave(opt.value);
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
if (opt.value === 'office') { setBuildingType('office'); setIndoorMode('on'); setIndoorManaged(false); }
else { setIndoorMode('off'); setIndoorManaged(false); }
}}
>
<span class="profile-btn-scene" style=${{ background: opt.scene }}></span>
<span class="profile-btn-label">${opt.name}</span>
</button>
`)}
</div>
</div>
</div>
@@ -363,7 +333,10 @@ export function DayTabs({
style=${{
background: isActive ? wBgActive : wBg,
color: wText,
border: '1px solid #c9b08a',
borderTop: '1px solid #c9b08a',
borderRight: '1px solid #c9b08a',
borderBottom: '1px solid #c9b08a',
borderLeft: i === 0 ? '1px solid #c9b08a' : 'none',
opacity: locked ? 0.5 : 1,
cursor: locked ? 'not-allowed' : 'pointer',
position: 'relative',
+5 -3
View File
@@ -117,9 +117,11 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v
// Suggested multipliers (tune with real data):
// Resting: 1.0 (no change)
// Walking: 0.85
// Cycling: 0.75
// Running: 0.60
// Sport: 0.50
// Cycling: 0.75 *This will need a airflow calc as going at speed is cooling
// Running: 0.60 *This will need a airflow calc as going at speed is cooling
// Sport: 0.50 *This will need a airflow calc as going at speed is cooling
//
// *Danny Notes: These faster speed activities might need intergrating into the future "Vehicle Speed" calcs somehow?
//
// heatStressLevel - a simple label: 'Low' | 'Moderate' | 'High' | 'Very High'
// Derived from UTCI + activity heat load. A runner at