diff --git a/.gitignore b/.gitignore index 40190d3..35428f3 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ dist/ *.ps1 *.py secrets.local.php +/data diff --git a/assets/css/base.css b/assets/css/base.css index fb23db2..af9f7e0 100644 --- a/assets/css/base.css +++ b/assets/css/base.css @@ -272,15 +272,27 @@ body { .event-note-slide { grid-area: 1 / 1; /* stack all slides on top of each other */ display: flex; - align-items: baseline; - flex-wrap: wrap; - gap: 4px 10px; + flex-direction: column; + gap: 2px; min-width: 0; opacity: 0; /* only .is-active is visible */ pointer-events: none; user-select: none; /* keeps hidden copy out of a drag-selection */ transition: opacity 0.35s ease; } + +.event-note-header { + display: flex; + align-items: baseline; + gap: 6px; +} + +.event-note-body { + display: flex; + align-items: baseline; + gap: 10px; + min-width: 0; +} .event-note-slide.is-active { opacity: 1; pointer-events: auto; @@ -351,6 +363,63 @@ body { transform: scale(1.3); } +/* Close control — a small brass "Close ×" pill in the top-right corner. It + sits at the end of the strip's flex row rather than being absolutely + positioned, so the dates line can never run underneath it. */ +.event-note-close { + display: inline-flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + align-self: flex-start; + margin: -2px -6px 0 2px; /* pull into the padding so it hugs the corner */ + padding: 3px 7px; + border: 1px solid transparent; + border-radius: 999px; + background: transparent; + font-family: Manrope, sans-serif; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #9a7d5a; + line-height: 1; + cursor: pointer; + transition: background 0.2s, color 0.2s, border-color 0.2s; +} +.event-note-close:hover { + background: rgba(200, 146, 42, 0.14); + border-color: rgba(200, 146, 42, 0.45); + color: #7a5a1e; +} +.event-note-close:focus-visible { + outline: 2px solid #c8922a; + outline-offset: 1px; +} +.event-note.is-priority .event-note-close { + color: #a06e18; +} +.event-note.is-tinted .event-note-close { + color: #56452c; +} +.event-note.is-tinted .event-note-close:hover { + background: rgba(0, 0, 0, 0.09); + border-color: rgba(0, 0, 0, 0.22); + color: #2a1a08; +} + +/* Collapse on the way out — height and vertical spacing animate to zero so + the page closes the gap smoothly instead of snapping upward. */ +.event-note.is-closing { + animation: event-note-out 0.26s ease forwards; + pointer-events: none; +} +@keyframes event-note-out { + from { opacity: 1; transform: translateY(0); max-height: 200px; } + to { opacity: 0; transform: translateY(-4px); max-height: 0; + margin-bottom: 0; padding-top: 0; padding-bottom: 0; border-width: 0; } +} + /* Above true-mobile: the nav drops into the flow beneath the note. Its own 32px side padding is dropped so the links line up with the note and the content rather than sitting 32px inside them. */ @@ -376,6 +445,15 @@ body { .event-note-dates { margin-left: 0; /* no room to right-align on a phone */ } + /* Phone: the cross alone, pushed to the right-hand end of the row and + padded out to a ~26px touch target. */ + .event-note-close { + margin: -4px -6px 0 auto; + padding: 8px; + } + .event-note-close-label { + display: none; + } } /* Nav logo — hidden on desktop, shown on mobile */ diff --git a/assets/js/app.js b/assets/js/app.js index 9c8d78e..3511a26 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -276,6 +276,31 @@ export function UTCIForecast() { 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); @@ -669,8 +694,8 @@ export function UTCIForecast() { // • "utci-about" — the explainer paragraphs at the bottom // • "utci-footer" — the "reading the table" note return html` -
0 ? ' has-event-note' : ''}`}> - ${activeEvents.length > 0 && (() => { +
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 @@ -703,7 +728,7 @@ export function UTCIForecast() { }; })() : undefined; return html` -
+
${/* 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 @@ -720,10 +745,14 @@ export function UTCIForecast() { class=${`event-note-slide${isActive ? ' is-active' : ''}`} aria-hidden=${isActive ? undefined : 'true'} > - ${e.emoji} - ${e.title} - ${e.message} - ${dateLine && html`${dateLine}`} +
+ ${e.emoji} + ${e.title} +
+
+ ${e.message} + ${dateLine && html`${dateLine}`} +
`; })}
@@ -738,6 +767,19 @@ export function UTCIForecast() { onClick=${() => noteGoTo(i)} />`)}
`} +
`; })()}