diff --git a/assets/css/table.css b/assets/css/table.css index 8a797f6..6fa3657 100644 --- a/assets/css/table.css +++ b/assets/css/table.css @@ -138,7 +138,7 @@ .filter-profiles-label { font-family: JetBrains Mono, monospace; - font-size: 9px; + font-size: 12px; text-transform: uppercase; letter-spacing: 0.18em; color: #b09870; @@ -147,13 +147,13 @@ } .profile-btn { - padding: 4px 11px; + padding: 5px 13px; cursor: pointer; border: 1.5px solid #c9b08a; background: #f5edd6; color: #9a7d5a; font-family: JetBrains Mono, monospace; - font-size: 11px; + font-size: 13px; text-transform: uppercase; letter-spacing: 0.1em; transition: all 0.15s; @@ -189,7 +189,7 @@ .col-toggles-label { font-family: JetBrains Mono, monospace; - font-size: 9px; + font-size: 12px; text-transform: uppercase; letter-spacing: 0.18em; color: #b09870; @@ -199,13 +199,13 @@ /* The little pill buttons */ .col-toggle { - padding: 4px 11px; + padding: 5px 13px; cursor: pointer; border: 1.5px solid #c9b08a; background: #f5edd6; color: #9a7d5a; font-family: JetBrains Mono, monospace; - font-size: 9px; + font-size: 12px; text-transform: uppercase; letter-spacing: 0.1em; transition: all 0.15s; @@ -226,16 +226,29 @@ color: #fff; } -/* Burn select — looks like a col-toggle pill but is a on the col-toggles bar */ + +.cs-wrap { + position: relative; + display: inline-block; +} + +/* The visible pill button */ +.cs-btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 10px 5px 13px; + cursor: pointer; + border: 1.5px solid #c9b08a; + background: #f5edd6; + color: #9a7d5a; + font-family: JetBrains Mono, monospace; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.1em; + transition: all 0.15s; + user-select: none; + white-space: nowrap; +} +.cs-btn:hover { + border-color: #c8922a; + color: #6b4f2a; + background: #fef3dc; +} +/* Active state — brass border + brass text, parchment background */ +.cs-btn.on { + border-color: #c8922a; + color: #c8922a; + background: #f5edd6; + font-weight: 600; +} +.cs-btn.on:hover { + background: #fef3dc; + color: #a06e18; + border-color: #a06e18; +} + +/* The chevron arrow inside the button */ +.cs-arrow { + font-size: 9px; + opacity: 0.6; + margin-left: 2px; + transition: transform 0.15s; + display: inline-block; +} +.cs-wrap.open .cs-arrow { + transform: rotate(180deg); +} + +/* Grouped left (fused with checkbox pill) */ +.cs-btn.grouped-left { + border-right: none; + border-radius: 20px 0 0 20px; +} +.cs-btn.grouped-left:only-child, +.cs-btn.grouped-left.last-child { + border-right: 1.5px solid #c9b08a; + border-radius: 20px; +} + +/* The floating dropdown panel */ +.cs-panel { + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 1000; + background: #fffcf2; + border: 1.5px solid #c9b08a; + border-radius: 4px; + box-shadow: 0 4px 16px rgba(30,18,8,0.13); + min-width: 100%; + white-space: nowrap; + overflow: hidden; +} + +/* Each option row */ +.cs-option { + display: block; + padding: 8px 14px; + font-family: JetBrains Mono, monospace; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #4a3420; + cursor: pointer; + transition: background 0.1s, color 0.1s; + user-select: none; +} +.cs-option:hover { + background: #fef3dc; + color: #1e1208; +} +.cs-option.selected { + color: #c8922a; + font-weight: 600; + background: #fdf8ee; +} +.cs-option.disabled { + opacity: 0.5; + cursor: default; +} +.cs-option.disabled:hover { + background: transparent; + color: #4a3420; +} +/* Hide option divider between show/hide */ +.cs-divider { + height: 1px; + background: #e8d8c0; + margin: 2px 0; +} + +/* ── CUSTOM CHECKBOX PILL (ventilation / managed) ───────────────────── */ + +.cs-vent { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 12px 5px 10px; + font-size: 12px; + font-family: JetBrains Mono, monospace; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; + border: 1.5px solid #c9b08a; + border-left: none; + margin-left: -1px; + padding-left: 11px; + border-radius: 0 20px 20px 0; + background: #f5edd6; + color: #9a7d5a; + user-select: none; + transition: background 0.15s, color 0.15s, border-color 0.15s; + white-space: nowrap; +} +.cs-vent:hover { + border-color: #c8922a; + color: #6b4f2a; + background: #fef3dc; +} +.cs-vent.on { + border-color: #c8922a; + color: #c8922a; + background: #f5edd6; + font-weight: 600; +} +.cs-vent.on:hover { + background: #fef3dc; + color: #a06e18; + border-color: #a06e18; +} + +/* The little square tick box inside the vent pill */ +.cs-checkbox { + display: inline-flex; + align-items: center; + justify-content: center; + width: 13px; + height: 13px; + border: 1.5px solid currentColor; + border-radius: 2px; + flex-shrink: 0; + transition: background 0.12s; +} +.cs-checkbox.checked::after { + content: ''; + display: block; + width: 7px; + height: 5px; + border-left: 2px solid currentColor; + border-bottom: 2px solid currentColor; + transform: rotate(-45deg) translateY(-1px); +} + + /* ── 9. STATUS & ERROR BANNERS ("Acquiring forecast data…") ────────── */ .utci-status { diff --git a/assets/js/app.js b/assets/js/app.js index 10df9ab..a6db99a 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -30,7 +30,7 @@ import { VEHICLE_TYPES, BUILDING_TYPES, cloudCategory, confidenceBand, moonGlyph, } from './utils.js'; -import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon } from './components.js'; +import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill } from './components.js'; const html = htm.bind(h); @@ -1068,11 +1068,17 @@ export function UTCIForecast() { Columns: ${activeProfile === 'outdoors' && html` - `} + }} + />`} ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['air']) && html``} ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['rh']) && html``} @@ -1105,84 +1109,87 @@ export function UTCIForecast() { ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['uvB']) && html``} ${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile]?.cols['burn']) && html` - `} + }} + />`} ${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile]?.cols['vehicleT']) && html` - + }} + /> ${visibleCols.vehicleT && html` - `} + <${VentPill} + checked=${vehicleVent} + onChange=${() => setVehicleVent(v => !v)} + label="Ventilation" + title="Ventilation — open windows significantly reduce cabin heat build-up" + />`} `} ${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['indoorT'] || FILTER_PROFILES[activeProfile].cols['managedT']) && html` - + }} + /> ${indoorMode === 'on' && html` - `} + <${VentPill} + checked=${indoorManaged} + onChange=${() => setIndoorManaged(v => !v)} + label="Managed" + title="Managed: curtains closed by day, windows open when cooler outside" + />`} `} ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['precip']) && html``} @@ -1456,4 +1463,3 @@ export function UTCIForecast() { `; } - diff --git a/assets/js/components.js b/assets/js/components.js index 0994b6a..8904e78 100644 --- a/assets/js/components.js +++ b/assets/js/components.js @@ -14,6 +14,7 @@ // ════════════════════════════════════════════════════════════════════════ import { h, Fragment } from '../vendor/preact.js'; +import { useState, useEffect, useRef } from '../vendor/preact-hooks.js'; import htm from '../vendor/htm.js'; import { skyGradientForElev, skyFillForElev, grassFillForElev, @@ -22,6 +23,113 @@ import { const html = htm.bind(h); +// ═══════════════════════════════════════════════════════════════════ +// CUSTOMSELECT — cross-platform styled dropdown pill. +// ─────────────────────────────────────────────────────────────────── +// props: +// value — current value string +// options — array of { value, label, disabled } +// onChange — fn(newValue) +// hideLabel — label shown when value === 'off' (e.g. 'Burn') +// hidingLabel — label shown for the 'off' option when active (e.g. 'Hide Burn') +// isOn — bool — whether the pill shows as active (brass) +// groupedLeft — bool — fuse right side with a VentPill +// isLastChild — bool — restore right border-radius when no sibling follows +// ═══════════════════════════════════════════════════════════════════ +export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel, isOn, groupedLeft, isLastChild }) { + const [open, setOpen] = useState(false); + const wrapRef = useRef(null); + + // Close on outside click or Escape + useEffect(() => { + if (!open) return; + const onDown = (e) => { + if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false); + }; + const onKey = (e) => { if (e.key === 'Escape') setOpen(false); }; + document.addEventListener('mousedown', onDown); + document.addEventListener('touchstart', onDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDown); + document.removeEventListener('touchstart', onDown); + document.removeEventListener('keydown', onKey); + }; + }, [open]); + + // Build the label shown on the button + const activeOpt = options.find(o => o.value === value); + const btnLabel = isOn + ? (activeOpt ? activeOpt.label : hideLabel) + : hideLabel; + + const btnClass = [ + 'cs-btn', + isOn ? 'on' : '', + groupedLeft ? 'grouped-left' : '', + (groupedLeft && isLastChild) ? 'last-child' : '', + ].filter(Boolean).join(' '); + + return html` + + + ${open && html` +
+ ${isOn && html` + { onChange('off'); setOpen(false); }} + >${hidingLabel || ('Hide ' + hideLabel)} +
+ `} + ${options.map(opt => html` + { + if (opt.disabled) return; + onChange(opt.value); + setOpen(false); + }} + >${opt.label} + `)} +
+ `} +
`; +} + +// ═══════════════════════════════════════════════════════════════════ +// VENTPILL — the checkbox pill fused to the right of a CustomSelect. +// ─────────────────────────────────────────────────────────────────── +// props: +// checked — bool +// onChange — fn() +// label — string +// title — tooltip string +// ═══════════════════════════════════════════════════════════════════ +export function VentPill({ checked, onChange, label, title }) { + return html` + `; +} + // ═══════════════════════════════════════════════════════════════════ // SKYSCOPE — the little porthole circle next to each hour. // ─────────────────────────────────────────────────────────────────── diff --git a/restore.php b/restore.php index 110ab6b..4477f02 100644 --- a/restore.php +++ b/restore.php @@ -4,7 +4,7 @@ // Paste your Stripe SECRET key below (never the publishable key). // Swap sk_test_... for sk_live_... when you go live. -define('STRIPE_SECRET_KEY', 'sk_test_51TD27GEwik8ohlUdzs6LZx9mu3NOIERBnlzPoOcdWA1wfxM8YqVnIEowEpCJI7nMIrXNZuPMbwnLz9CgYbUrORkV00Yzf1MfVI'); +define('STRIPE_SECRET_KEY', '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'); define('SUNSCOPE_URL', 'https://sunscope.net'); $error = '';