Live swipe feedback: content tracks finger with destination peek
All checks were successful
Test, Build & Deploy / test-and-build (push) Successful in 41s
Test, Build & Deploy / deploy (push) Successful in 25s

Replace the instant-jump swipe with a live drag on touch: the current tab
content translates with the finger in real time (translateX driven by
touchmove), a peek chip at the edge shows the destination tab, and on
release the sheet animates to the next tab or snaps back. Horizontal vs
vertical travel is decided after 12px so normal page scrolling is
untouched; touch-action:pan-y keeps horizontal gestures JS-owned. Radar
still pans its map instead of switching tabs.
This commit is contained in:
hermes-explorigin 2026-08-30 19:22:35 +00:00
parent 09cea275b7
commit f5488eaa98
2 changed files with 388 additions and 93 deletions

251
dist/index.html vendored
View File

@ -7188,7 +7188,7 @@ var root_4$3 = /* @__PURE__ */ from_html(`<span class="lightning-strike svelte-t
var root_5$3 = /* @__PURE__ */ from_html(`<div class="lightning-layer svelte-tjvfz7" aria-label="Lightning strikes"></div>`); var root_5$3 = /* @__PURE__ */ from_html(`<div class="lightning-layer svelte-tjvfz7" aria-label="Lightning strikes"></div>`);
var root_6$2 = /* @__PURE__ */ from_html(`<div class="radar-layer svelte-tjvfz7"><!> <!></div> <div class="location-marker svelte-tjvfz7"><span class="marker-dot svelte-tjvfz7"></span></div>`, 1); var root_6$2 = /* @__PURE__ */ from_html(`<div class="radar-layer svelte-tjvfz7"><!> <!></div> <div class="location-marker svelte-tjvfz7"><span class="marker-dot svelte-tjvfz7"></span></div>`, 1);
var root_7$1 = /* @__PURE__ */ from_html(`<div class="radar-overlay svelte-tjvfz7"><span class="loading-spinner svelte-tjvfz7"></span> <span class="text-xs text-muted svelte-tjvfz7">Loading radar...</span></div>`); var root_7$1 = /* @__PURE__ */ from_html(`<div class="radar-overlay svelte-tjvfz7"><span class="loading-spinner svelte-tjvfz7"></span> <span class="text-xs text-muted svelte-tjvfz7">Loading radar...</span></div>`);
var root_8 = /* @__PURE__ */ from_html(`<div class="radar-error-banner svelte-tjvfz7"><span class="text-xs svelte-tjvfz7"> </span> <button class="btn btn-ghost btn-sm svelte-tjvfz7">Retry</button></div>`); var root_8$1 = /* @__PURE__ */ from_html(`<div class="radar-error-banner svelte-tjvfz7"><span class="text-xs svelte-tjvfz7"> </span> <button class="btn btn-ghost btn-sm svelte-tjvfz7">Retry</button></div>`);
var root_9 = /* @__PURE__ */ from_html(`<div class="lightning-status svelte-tjvfz7"><span class="text-xs svelte-tjvfz7">⏳ Loading lightning...</span></div>`); var root_9 = /* @__PURE__ */ from_html(`<div class="lightning-status svelte-tjvfz7"><span class="text-xs svelte-tjvfz7">⏳ Loading lightning...</span></div>`);
var root_10 = /* @__PURE__ */ from_html(`<div class="lightning-status lightning-status-err svelte-tjvfz7"><span class="text-xs svelte-tjvfz7"> </span></div>`); var root_10 = /* @__PURE__ */ from_html(`<div class="lightning-status lightning-status-err svelte-tjvfz7"><span class="text-xs svelte-tjvfz7"> </span></div>`);
var root_11 = /* @__PURE__ */ from_html(`<div class="lightning-status svelte-tjvfz7"><span class="text-xs svelte-tjvfz7">⚡ No recent strikes nearby</span></div>`); var root_11 = /* @__PURE__ */ from_html(`<div class="lightning-status svelte-tjvfz7"><span class="text-xs svelte-tjvfz7">⚡ No recent strikes nearby</span></div>`);
@ -7536,7 +7536,7 @@ function PrecipitationRadar($$anchor, $$props) {
}); });
var node_7 = sibling(node_6, 2); var node_7 = sibling(node_6, 2);
var consequent_6 = ($$anchor) => { var consequent_6 = ($$anchor) => {
var div_8 = root_8(); var div_8 = root_8$1();
var span_2 = child(div_8); var span_2 = child(div_8);
var text_1 = child(span_2); var text_1 = child(span_2);
reset(span_2); reset(span_2);
@ -8250,9 +8250,10 @@ var root_1 = /* @__PURE__ */ from_html(`<span class="current-badge svelte-1n46o8
var root_2 = /* @__PURE__ */ from_html(`<div class="error-banner animate-slide-down svelte-1n46o8q"><span> </span> <button class="btn-icon" aria-label="Dismiss"></button></div>`); var root_2 = /* @__PURE__ */ from_html(`<div class="error-banner animate-slide-down svelte-1n46o8q"><span> </span> <button class="btn-icon" aria-label="Dismiss"></button></div>`);
var root_3 = /* @__PURE__ */ from_html(`<div class="empty-state svelte-1n46o8q"><div class="empty-icon svelte-1n46o8q">🌍</div> <h2 class="svelte-1n46o8q">Welcome to WeatherLens</h2> <p class="text-muted"> </p> <button class="btn btn-primary mt-3">+ Add a Location</button></div>`); var root_3 = /* @__PURE__ */ from_html(`<div class="empty-state svelte-1n46o8q"><div class="empty-icon svelte-1n46o8q">🌍</div> <h2 class="svelte-1n46o8q">Welcome to WeatherLens</h2> <p class="text-muted"> </p> <button class="btn btn-primary mt-3">+ Add a Location</button></div>`);
var root_4 = /* @__PURE__ */ from_html(`<div class="loading-state svelte-1n46o8q"><div class="loading-spinner svelte-1n46o8q"></div> <p class="text-muted mt-2">Loading weather data...</p></div>`); var root_4 = /* @__PURE__ */ from_html(`<div class="loading-state svelte-1n46o8q"><div class="loading-spinner svelte-1n46o8q"></div> <p class="text-muted mt-2">Loading weather data...</p></div>`);
var root_5 = /* @__PURE__ */ from_html(`<div class="animate-fade-in"><!></div>`); var root_5 = /* @__PURE__ */ from_html(`<span aria-hidden="true"> </span>`);
var root_6 = /* @__PURE__ */ from_html(`<div class="mobile-nav svelte-1n46o8q"><button>Now</button> <button>Hourly</button> <button>Daily</button> <button>More</button> <button>Radar</button></div> <div class="desktop-tabs svelte-1n46o8q"><button>Current</button> <button>Hourly</button> <button>7-Day Forecast</button> <button>Details</button> <button>Radar</button></div> <div class="content-area svelte-1n46o8q"><!></div>`, 1); var root_6 = /* @__PURE__ */ from_html(`<div class="animate-fade-in"><!></div>`);
var root_7 = /* @__PURE__ */ from_html(`<div class="app-shell svelte-1n46o8q"><div class="mobile-header svelte-1n46o8q"><button class="btn-icon" aria-label="Toggle menu"> </button> <span class="mobile-title svelte-1n46o8q"> </span> <button class="btn-icon" aria-label="Settings" title="Settings">⚙️</button> <!></div> <!> <aside><!></aside> <main class="main-content svelte-1n46o8q"><div class="top-bar svelte-1n46o8q"><div class="top-bar-title svelte-1n46o8q"><h1 class="svelte-1n46o8q"> </h1> <!></div> <div class="top-bar-actions svelte-1n46o8q"><!></div></div> <!> <!> <!> <!></main> <!> <!></div>`); var root_7 = /* @__PURE__ */ from_html(`<div class="mobile-nav svelte-1n46o8q"><button>Now</button> <button>Hourly</button> <button>Daily</button> <button>More</button> <button>Radar</button></div> <div class="desktop-tabs svelte-1n46o8q"><button>Current</button> <button>Hourly</button> <button>7-Day Forecast</button> <button>Details</button> <button>Radar</button></div> <div><div><!> <!></div></div>`, 1);
var root_8 = /* @__PURE__ */ from_html(`<div class="app-shell svelte-1n46o8q"><div class="mobile-header svelte-1n46o8q"><button class="btn-icon" aria-label="Toggle menu"> </button> <span class="mobile-title svelte-1n46o8q"> </span> <button class="btn-icon" aria-label="Settings" title="Settings">⚙️</button> <!></div> <!> <aside><!></aside> <main class="main-content svelte-1n46o8q"><div class="top-bar svelte-1n46o8q"><div class="top-bar-title svelte-1n46o8q"><h1 class="svelte-1n46o8q"> </h1> <!></div> <div class="top-bar-actions svelte-1n46o8q"><!></div></div> <!> <!> <!> <!></main> <!> <!></div>`);
function App($$anchor, $$props) { function App($$anchor, $$props) {
push($$props, true); push($$props, true);
let viewMode = /* @__PURE__ */ state(proxy(parseHash(typeof window !== "undefined" ? window.location.hash : ""))); let viewMode = /* @__PURE__ */ state(proxy(parseHash(typeof window !== "undefined" ? window.location.hash : "")));
@ -8270,26 +8271,94 @@ function App($$anchor, $$props) {
window.addEventListener("hashchange", onHash); window.addEventListener("hashchange", onHash);
return () => window.removeEventListener("hashchange", onHash); return () => window.removeEventListener("hashchange", onHash);
}); });
const SWIPE_THRESHOLD = 60; const SWIPE_THRESHOLD = 70;
let touchAnchor = /* @__PURE__ */ state(null); const AXIS_THRESHOLD = 12;
const SWIPE_TIMEOUT = 160;
const TAB_LABELS = {
current: "Now",
hourly: "Hourly",
daily: "Daily",
detail: "More",
radar: "Radar"
};
let contentEl = /* @__PURE__ */ state(null);
let swiping = /* @__PURE__ */ state(false);
let swipeAnchor = /* @__PURE__ */ state(null);
let swipeDX = /* @__PURE__ */ state(0);
let swipeLock = /* @__PURE__ */ state(null);
let settling = /* @__PURE__ */ state(false);
const clampIdx = (i) => Math.min(Math.max(i, 0), VALID_VIEWS.length - 1);
const peekView = /* @__PURE__ */ user_derived(() => get(swiping) && get(swipeLock) === "x" && VALID_VIEWS.includes(get(viewMode)) ? VALID_VIEWS[clampIdx(VALID_VIEWS.indexOf(get(viewMode)) + (get(swipeDX) < 0 ? 1 : -1))] : null);
function onSwipeStart(e) { function onSwipeStart(e) {
if (get(viewMode) === "radar" || !e.touches || !e.touches[0]) return; if (get(viewMode) === "radar" || !e.touches || !e.touches[0]) return;
set(touchAnchor, { const t = e.touches[0];
x: e.touches[0].clientX, set(swipeAnchor, {
y: e.touches[0].clientY x: t.clientX,
y: t.clientY
}, true); }, true);
set(swipeDX, 0);
set(swipeLock, null);
set(settling, false);
set(swiping, true);
} }
function onSwipeEnd(e) { function onSwipeMove(e) {
if (get(viewMode) === "radar" || !get(touchAnchor) || !e.changedTouches || !e.changedTouches[0]) return; if (!get(swiping) || !get(swipeAnchor)) return;
const t = e.changedTouches[0]; const t = e.touches[0];
const dx = t.clientX - get(touchAnchor).x; const dx = t.clientX - get(swipeAnchor).x;
const dy = t.clientY - get(touchAnchor).y; const dy = t.clientY - get(swipeAnchor).y;
set(touchAnchor, null); if (!get(swipeLock)) {
if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dx) < Math.abs(dy)) return; if (Math.abs(dx) > AXIS_THRESHOLD && Math.abs(dx) > Math.abs(dy)) set(swipeLock, "x");
const idx = VALID_VIEWS.indexOf(get(viewMode)); else if (Math.abs(dy) > AXIS_THRESHOLD) set(swipeLock, "y");
const next = VALID_VIEWS[Math.min(Math.max(idx + (dx < 0 ? 1 : -1), 0), VALID_VIEWS.length - 1)]; }
if (next !== get(viewMode)) setView(next); if (get(swipeLock) === "x") {
e.preventDefault();
set(swipeDX, dx);
} else if (get(swipeLock) === "y") set(swipeDX, 0);
} }
function finishSwipe(e) {
if (!get(swiping)) return;
const t = e.changedTouches && e.changedTouches[0];
const dx = get(swipeLock) === "x" ? t ? t.clientX - get(swipeAnchor).x : get(swipeDX) : get(swipeDX);
const dy = t ? t.clientY - get(swipeAnchor).y : 0;
if (get(swipeLock) === "x" && Math.abs(dx) >= SWIPE_THRESHOLD && Math.abs(dx) >= Math.abs(dy) && get(viewMode) !== "radar") {
const idx = VALID_VIEWS.indexOf(get(viewMode));
const step = dx < 0 ? 1 : -1;
const next = VALID_VIEWS[clampIdx(idx + step)];
if (next !== get(viewMode)) {
set(settling, true);
set(swipeDX, (get(contentEl) ? get(contentEl).clientWidth : 420) * step * -1);
setTimeout(() => {
setView(next);
set(swiping, false);
set(swipeDX, 0);
set(swipeLock, null);
set(swipeAnchor, null);
set(settling, false);
}, SWIPE_TIMEOUT);
return;
}
}
set(settling, true);
set(swipeDX, 0);
setTimeout(() => {
set(swiping, false);
set(swipeLock, null);
set(swipeAnchor, null);
set(settling, false);
}, SWIPE_TIMEOUT);
}
function cancelSwipe(e) {
if (!get(swiping)) return;
set(settling, true);
set(swipeDX, 0);
setTimeout(() => {
set(swiping, false);
set(swipeLock, null);
set(swipeAnchor, null);
set(settling, false);
}, SWIPE_TIMEOUT);
}
const slideStyle = /* @__PURE__ */ user_derived(() => get(swipeDX) ? `transform: translateX(${get(swipeDX)}px)` : "");
let initDone = /* @__PURE__ */ state(false); let initDone = /* @__PURE__ */ state(false);
let geoError = /* @__PURE__ */ state(""); let geoError = /* @__PURE__ */ state("");
user_effect(() => { user_effect(() => {
@ -8361,7 +8430,7 @@ function App($$anchor, $$props) {
const newest = updated[updated.length - 1]; const newest = updated[updated.length - 1];
if (newest) app$1.selectLocation(newest.id); if (newest) app$1.selectLocation(newest.id);
} }
var div = root_7(); var div = root_8();
var div_1 = child(div); var div_1 = child(div);
var button = child(div_1); var button = child(div_1);
var text = child(button, true); var text = child(button, true);
@ -8441,8 +8510,8 @@ function App($$anchor, $$props) {
if (app$1.loading && !app$1.forecastData) $$render(consequent_4); if (app$1.loading && !app$1.forecastData) $$render(consequent_4);
}); });
var node_8 = sibling(node_7, 2); var node_8 = sibling(node_7, 2);
var consequent_10 = ($$anchor) => { var consequent_11 = ($$anchor) => {
var fragment = root_6(); var fragment = root_7();
var div_8 = first_child(fragment); var div_8 = first_child(fragment);
var button_5 = child(div_8); var button_5 = child(div_8);
let classes_1; let classes_1;
@ -8468,45 +8537,68 @@ function App($$anchor, $$props) {
let classes_10; let classes_10;
reset(div_9); reset(div_9);
var div_10 = sibling(div_9, 2); var div_10 = sibling(div_9, 2);
var node_9 = child(div_10); let classes_11;
var div_11 = child(div_10);
let classes_12;
var node_9 = child(div_11);
var consequent_5 = ($$anchor) => { var consequent_5 = ($$anchor) => {
var div_11 = root_5(); var span_3 = root_5();
CurrentWeather(child(div_11), {}); let classes_13;
reset(div_11); var text_5 = child(span_3, true);
append($$anchor, div_11); reset(span_3);
template_effect(() => {
classes_13 = set_class(span_3, 1, "swipe-peek svelte-1n46o8q", null, classes_13, {
"from-left": get(swipeDX) >= 0,
"from-right": get(swipeDX) < 0
});
set_text(text_5, TAB_LABELS[get(peekView)]);
});
append($$anchor, span_3);
}; };
if_block(node_9, ($$render) => {
if (get(peekView) && TAB_LABELS[get(peekView)]) $$render(consequent_5);
});
var node_10 = sibling(node_9, 2);
var consequent_6 = ($$anchor) => { var consequent_6 = ($$anchor) => {
var div_12 = root_5(); var div_12 = root_6();
HourlyForecast(child(div_12), {}); CurrentWeather(child(div_12), {});
reset(div_12); reset(div_12);
append($$anchor, div_12); append($$anchor, div_12);
}; };
var consequent_7 = ($$anchor) => { var consequent_7 = ($$anchor) => {
var div_13 = root_5(); var div_13 = root_6();
DailyForecast(child(div_13), {}); HourlyForecast(child(div_13), {});
reset(div_13); reset(div_13);
append($$anchor, div_13); append($$anchor, div_13);
}; };
var consequent_8 = ($$anchor) => { var consequent_8 = ($$anchor) => {
var div_14 = root_5(); var div_14 = root_6();
WeatherDetail(child(div_14), {}); DailyForecast(child(div_14), {});
reset(div_14); reset(div_14);
append($$anchor, div_14); append($$anchor, div_14);
}; };
var consequent_9 = ($$anchor) => { var consequent_9 = ($$anchor) => {
var div_15 = root_5(); var div_15 = root_6();
PrecipitationRadar(child(div_15), {}); WeatherDetail(child(div_15), {});
reset(div_15); reset(div_15);
append($$anchor, div_15); append($$anchor, div_15);
}; };
if_block(node_9, ($$render) => { var consequent_10 = ($$anchor) => {
if (get(viewMode) === "current") $$render(consequent_5); var div_16 = root_6();
else if (get(viewMode) === "hourly") $$render(consequent_6, 1); PrecipitationRadar(child(div_16), {});
else if (get(viewMode) === "daily") $$render(consequent_7, 2); reset(div_16);
else if (get(viewMode) === "detail") $$render(consequent_8, 3); append($$anchor, div_16);
else if (get(viewMode) === "radar") $$render(consequent_9, 4); };
if_block(node_10, ($$render) => {
if (get(viewMode) === "current") $$render(consequent_6);
else if (get(viewMode) === "hourly") $$render(consequent_7, 1);
else if (get(viewMode) === "daily") $$render(consequent_8, 2);
else if (get(viewMode) === "detail") $$render(consequent_9, 3);
else if (get(viewMode) === "radar") $$render(consequent_10, 4);
}); });
reset(div_11);
reset(div_10); reset(div_10);
bind_this(div_10, ($$value) => set(contentEl, $$value), () => get(contentEl));
template_effect(() => { template_effect(() => {
classes_1 = set_class(button_5, 1, "nav-btn svelte-1n46o8q", null, classes_1, { active: get(viewMode) === "current" }); classes_1 = set_class(button_5, 1, "nav-btn svelte-1n46o8q", null, classes_1, { active: get(viewMode) === "current" });
classes_2 = set_class(button_6, 1, "nav-btn svelte-1n46o8q", null, classes_2, { active: get(viewMode) === "hourly" }); classes_2 = set_class(button_6, 1, "nav-btn svelte-1n46o8q", null, classes_2, { active: get(viewMode) === "hourly" });
@ -8518,6 +8610,9 @@ function App($$anchor, $$props) {
classes_8 = set_class(button_12, 1, "tab-btn svelte-1n46o8q", null, classes_8, { active: get(viewMode) === "daily" }); classes_8 = set_class(button_12, 1, "tab-btn svelte-1n46o8q", null, classes_8, { active: get(viewMode) === "daily" });
classes_9 = set_class(button_13, 1, "tab-btn svelte-1n46o8q", null, classes_9, { active: get(viewMode) === "detail" }); classes_9 = set_class(button_13, 1, "tab-btn svelte-1n46o8q", null, classes_9, { active: get(viewMode) === "detail" });
classes_10 = set_class(button_14, 1, "tab-btn svelte-1n46o8q", null, classes_10, { active: get(viewMode) === "radar" }); classes_10 = set_class(button_14, 1, "tab-btn svelte-1n46o8q", null, classes_10, { active: get(viewMode) === "radar" });
classes_11 = set_class(div_10, 1, "content-area svelte-1n46o8q", null, classes_11, { swiping: get(swiping) });
classes_12 = set_class(div_11, 1, "swipe-layer svelte-1n46o8q", null, classes_12, { settle: get(settling) });
set_style(div_11, get(slideStyle));
}); });
delegated("click", button_5, () => setView("current")); delegated("click", button_5, () => setView("current"));
delegated("click", button_6, () => setView("hourly")); delegated("click", button_6, () => setView("hourly"));
@ -8530,22 +8625,24 @@ function App($$anchor, $$props) {
delegated("click", button_13, () => setView("detail")); delegated("click", button_13, () => setView("detail"));
delegated("click", button_14, () => setView("radar")); delegated("click", button_14, () => setView("radar"));
delegated("touchstart", div_10, onSwipeStart, void 0, true); delegated("touchstart", div_10, onSwipeStart, void 0, true);
delegated("touchend", div_10, onSwipeEnd); delegated("touchmove", div_10, onSwipeMove, void 0, true);
delegated("touchend", div_10, finishSwipe);
event("touchcancel", div_10, cancelSwipe);
append($$anchor, fragment); append($$anchor, fragment);
}; };
if_block(node_8, ($$render) => { if_block(node_8, ($$render) => {
if (app$1.forecastData && app$1.selectedLocation) $$render(consequent_10); if (app$1.forecastData && app$1.selectedLocation) $$render(consequent_11);
}); });
reset(main); reset(main);
var node_15 = sibling(main, 2); var node_16 = sibling(main, 2);
var consequent_11 = ($$anchor) => { var consequent_12 = ($$anchor) => {
SettingsDialog($$anchor, { onClose: () => app$1.showSettings = false }); SettingsDialog($$anchor, { onClose: () => app$1.showSettings = false });
}; };
if_block(node_15, ($$render) => { if_block(node_16, ($$render) => {
if (app$1.showSettings) $$render(consequent_11); if (app$1.showSettings) $$render(consequent_12);
}); });
var node_16 = sibling(node_15, 2); var node_17 = sibling(node_16, 2);
var consequent_12 = ($$anchor) => { var consequent_13 = ($$anchor) => {
AddLocationDialog($$anchor, { AddLocationDialog($$anchor, {
onClose: () => { onClose: () => {
app$1.showAddLocation = false; app$1.showAddLocation = false;
@ -8554,8 +8651,8 @@ function App($$anchor, $$props) {
onAdd: handleAddLocation onAdd: handleAddLocation
}); });
}; };
if_block(node_16, ($$render) => { if_block(node_17, ($$render) => {
if (app$1.showAddLocation) $$render(consequent_12); if (app$1.showAddLocation) $$render(consequent_13);
}); });
reset(div); reset(div);
template_effect(() => { template_effect(() => {
@ -8572,11 +8669,12 @@ function App($$anchor, $$props) {
delegate([ delegate([
"click", "click",
"touchstart", "touchstart",
"touchmove",
"touchend" "touchend"
]); ]);
//#endregion //#endregion
//#region src/main.js //#region src/main.js
console.info({ commit_hash: "93e5660a5c76fd41088193f5ca3e3a35cd7478b1" }); console.info({ commit_hash: "09cea275b7ac2fd46d2184d84524ee1c3ddcbe60" });
mount(App, { target: document.getElementById("app") }); mount(App, { target: document.getElementById("app") });
//#endregion</script> //#endregion</script>
<style rel="stylesheet" crossorigin>/* ===== CSS Reset & Base ===== */ <style rel="stylesheet" crossorigin>/* ===== CSS Reset & Base ===== */
@ -10914,6 +11012,53 @@ input::placeholder {
min-width: 0; min-width: 0;
} }
.swipe-layer.svelte-1n46o8q {
position: relative;
min-height: 100%;
will-change: transform;
touch-action: pan-y;
}
.swipe-layer.settle.svelte-1n46o8q {
transition: transform 160ms ease-out;
}
.content-area.swiping.svelte-1n46o8q {
cursor: grabbing;
}
.swipe-peek.svelte-1n46o8q {
position: absolute;
top: 8px;
z-index: 5;
padding: 5px 10px;
border-radius: var(--radius-md);
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-primary);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
opacity: 0;
animation: svelte-1n46o8q-peek-in 160ms ease-out forwards;
pointer-events: none;
}
.swipe-peek.from-left.svelte-1n46o8q {
left: 8px;
}
.swipe-peek.from-right.svelte-1n46o8q {
right: 8px;
}
@keyframes svelte-1n46o8q-peek-in {
from { opacity: 0; transform: translateX(10px); }
to { opacity: 1; transform: translateX(0); }
}
/* Responsive */ /* Responsive */
@media (max-width: 1023px) { @media (max-width: 1023px) {
.app-shell.svelte-1n46o8q { .app-shell.svelte-1n46o8q {

View File

@ -36,33 +36,114 @@
return () => window.removeEventListener('hashchange', onHash) return () => window.removeEventListener('hashchange', onHash)
}) })
// --- Mobile swipe-to-switch tabs --- // --- Mobile swipe-to-switch tabs (live drag + visual feedback) ---
// Horizontal swipe on the content area moves between tabs (touch only). // Horizontal swipe on the content area moves between tabs (touch only). The
// content translates with the finger in real time, showing a "peek" chip of
// the destination, then animates to the next tab (or snaps back) on release.
// Gated to non-radar views: the radar map handles its own pan gestures via // Gated to non-radar views: the radar map handles its own pan gestures via
// pointer events, so a swipe there should pan the map, not switch tabs. // pointer events, so a swipe there should pan the map, not switch tabs.
const SWIPE_THRESHOLD = 60 // px of horizontal travel before it counts as a swipe const SWIPE_THRESHOLD = 70 // px of horizontal travel before it counts as a swipe
let touchAnchor = $state(null) const AXIS_THRESHOLD = 12 // travel before we decide horizontal vs vertical
const SWIPE_TIMEOUT = 160 // ms for the snap-back / snap-forward animation
const TAB_LABELS = { current: 'Now', hourly: 'Hourly', daily: 'Daily', detail: 'More', radar: 'Radar' }
let contentEl = $state(null) // bound to .content-area, gives travel distance
let swiping = $state(false) // a touch drag is in progress
let swipeAnchor = $state(null) // { x, y } of the starting touch
let swipeDX = $state(0) // live horizontal travel in px (drives the transform)
let swipeLock = $state(null) // 'x' | 'y' | null once the axis is decided
let settling = $state(false) // true while animating on release, enables CSS transition
const clampIdx = (i) => Math.min(Math.max(i, 0), VALID_VIEWS.length - 1)
// The destination view to hint at as the finger drags (only after we've
// committed to a horizontal swipe, so vertical scrolling shows nothing).
const peekView = $derived(
swiping && swipeLock === 'x' && VALID_VIEWS.includes(viewMode)
? VALID_VIEWS[clampIdx(VALID_VIEWS.indexOf(viewMode) + (swipeDX < 0 ? 1 : -1))]
: null
)
function onSwipeStart(e) { function onSwipeStart(e) {
if (viewMode === 'radar' || !e.touches || !e.touches[0]) return if (viewMode === 'radar' || !e.touches || !e.touches[0]) return
touchAnchor = { x: e.touches[0].clientX, y: e.touches[0].clientY } const t = e.touches[0]
swipeAnchor = { x: t.clientX, y: t.clientY }
swipeDX = 0
swipeLock = null
settling = false
swiping = true
} }
function onSwipeEnd(e) { function onSwipeMove(e) {
if (viewMode === 'radar' || !touchAnchor || !e.changedTouches || !e.changedTouches[0]) return if (!swiping || !swipeAnchor) return
const t = e.changedTouches[0] const t = e.touches[0]
const dx = t.clientX - touchAnchor.x const dx = t.clientX - swipeAnchor.x
const dy = t.clientY - touchAnchor.y const dy = t.clientY - swipeAnchor.y
touchAnchor = null // Decide the axis the first time we travel far enough; once locked to Y we
// Require a mostly-horizontal swipe past the threshold so vertical // leave default scrolling alone and never pop the peek nipple.
// scrolling stays untouched. if (!swipeLock) {
if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dx) < Math.abs(dy)) return if (Math.abs(dx) > AXIS_THRESHOLD && Math.abs(dx) > Math.abs(dy)) swipeLock = 'x'
const idx = VALID_VIEWS.indexOf(viewMode) else if (Math.abs(dy) > AXIS_THRESHOLD) swipeLock = 'y'
const step = dx < 0 ? 1 : -1 // left swipe = next tab, right = previous }
const next = VALID_VIEWS[Math.min(Math.max(idx + step, 0), VALID_VIEWS.length - 1)] if (swipeLock === 'x') {
if (next !== viewMode) setView(next) e.preventDefault() // stop the page from scrolling while we drag sideways
swipeDX = dx
} else if (swipeLock === 'y') {
swipeDX = 0 // let vertical scroll pass through
}
} }
function finishSwipe(e) {
if (!swiping) return
const t = e.changedTouches && e.changedTouches[0]
const dx = swipeLock === 'x' ? (t ? t.clientX - swipeAnchor.x : swipeDX) : swipeDX
const dy = t ? t.clientY - swipeAnchor.y : 0
const horizontal = swipeLock === 'x' && Math.abs(dx) >= SWIPE_THRESHOLD && Math.abs(dx) >= Math.abs(dy)
if (horizontal && viewMode !== 'radar') {
const idx = VALID_VIEWS.indexOf(viewMode)
const step = dx < 0 ? 1 : -1 // left swipe = next, right = previous
const next = VALID_VIEWS[clampIdx(idx + step)]
if (next !== viewMode) {
// Animate the current sheet the rest of the way off-screen, then swap.
settling = true
swipeDX = (contentEl ? contentEl.clientWidth : 420) * step * -1
setTimeout(() => {
setView(next)
swiping = false
swipeDX = 0
swipeLock = null
swipeAnchor = null
settling = false
}, SWIPE_TIMEOUT)
return
}
}
// Not a switch: ease the sheet back to rest.
settling = true
swipeDX = 0
setTimeout(() => {
swiping = false
swipeLock = null
swipeAnchor = null
settling = false
}, SWIPE_TIMEOUT)
}
function cancelSwipe(e) {
if (!swiping) return
settling = true
swipeDX = 0
setTimeout(() => {
swiping = false
swipeLock = null
swipeAnchor = null
settling = false
}, SWIPE_TIMEOUT)
}
const slideStyle = $derived(swipeDX ? `transform: translateX(${swipeDX}px)` : '')
let initDone = $state(false) let initDone = $state(false)
let geoError = $state('') let geoError = $state('')
@ -271,28 +352,50 @@
</div> </div>
<!-- Content area --> <!-- Content area -->
<div class="content-area" ontouchstart={onSwipeStart} ontouchend={onSwipeEnd}> <div
{#if viewMode === 'current'} class="content-area"
<div class="animate-fade-in"> class:swiping={swiping}
<CurrentWeather /> bind:this={contentEl}
</div> ontouchstart={onSwipeStart}
{:else if viewMode === 'hourly'} ontouchmove={onSwipeMove}
<div class="animate-fade-in"> ontouchend={finishSwipe}
<HourlyForecast /> ontouchcancel={cancelSwipe}
</div> >
{:else if viewMode === 'daily'} <div
<div class="animate-fade-in"> class="swipe-layer"
<DailyForecast /> class:settle={settling}
</div> style={slideStyle}
{:else if viewMode === 'detail'} >
<div class="animate-fade-in"> {#if peekView && TAB_LABELS[peekView]}
<WeatherDetail /> <span
</div> class="swipe-peek"
{:else if viewMode === 'radar'} class:from-left={swipeDX >= 0}
<div class="animate-fade-in"> class:from-right={swipeDX < 0}
<PrecipitationRadar /> aria-hidden="true"
</div> >{TAB_LABELS[peekView]}</span>
{/if} {/if}
{#if viewMode === 'current'}
<div class="animate-fade-in">
<CurrentWeather />
</div>
{:else if viewMode === 'hourly'}
<div class="animate-fade-in">
<HourlyForecast />
</div>
{:else if viewMode === 'daily'}
<div class="animate-fade-in">
<DailyForecast />
</div>
{:else if viewMode === 'detail'}
<div class="animate-fade-in">
<WeatherDetail />
</div>
{:else if viewMode === 'radar'}
<div class="animate-fade-in">
<PrecipitationRadar />
</div>
{/if}
</div>
</div> </div>
{/if} {/if}
</main> </main>
@ -552,6 +655,53 @@
min-width: 0; min-width: 0;
} }
.swipe-layer {
position: relative;
min-height: 100%;
will-change: transform;
touch-action: pan-y;
}
.swipe-layer.settle {
transition: transform 160ms ease-out;
}
.content-area.swiping {
cursor: grabbing;
}
.swipe-peek {
position: absolute;
top: 8px;
z-index: 5;
padding: 5px 10px;
border-radius: var(--radius-md);
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-primary);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
opacity: 0;
animation: peek-in 160ms ease-out forwards;
pointer-events: none;
}
.swipe-peek.from-left {
left: 8px;
}
.swipe-peek.from-right {
right: 8px;
}
@keyframes peek-in {
from { opacity: 0; transform: translateX(10px); }
to { opacity: 1; transform: translateX(0); }
}
/* Responsive */ /* Responsive */
@media (max-width: 1023px) { @media (max-width: 1023px) {
.app-shell { .app-shell {