Install-as-app guide and iPhone safe-area support
Publish Container / publish (push) Successful in 2m51s

- Install banner (phones), Settings guide and dialog with iPhone/iPad and Android steps,
  native Install button where supported; steps also on the dashboard Account page
- apple-touch-icon now a PNG (iOS ignores SVG icons)
- Shared pwa-safe.css: dark cap behind the status bar/Dynamic Island in the installed app,
  inset-aware offline banner, pull-to-refresh, bottom bars, full-screen map, dialogs
- Landscape-aware content gutters; measured top-bar height; viewport-fit=cover on every page

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Nate EmmertandClaude Sonnet 5.5 committed 2026-10-15 13:34:22 -04:00
1 parent 114386a1b2
commit 5ed673bacc
11 files changed
+223 -14

No files matched your search

+41 -8
View File
@@ -2,7 +2,8 @@
:root { :root {
--sky-text: #ffffff; --sky-text: #ffffff;
--blue-grad: linear-gradient(160deg, #2563eb 0%, #3b82f6 55%, #93c5fd 100%); --blue-grad: linear-gradient(160deg, #2563eb 0%, #3b82f6 55%, #93c5fd 100%);
--topbar-h: 58px; --topbar-h: calc(58px + env(safe-area-inset-top, 0px));
--gutter: 1rem;
} }
html { -webkit-text-size-adjust: 100%; -webkit-tap-highlight-color: transparent; } html { -webkit-text-size-adjust: 100%; -webkit-tap-highlight-color: transparent; }
/* on <html> (the viewport): putting this on <body> can block wheel scrolling when body is a scroll container */ /* on <html> (the viewport): putting this on <body> can block wheel scrolling when body is a scroll container */
@@ -10,20 +11,20 @@ html { overscroll-behavior-y: none; }
button, a, summary { touch-action: manipulation; } button, a, summary { touch-action: manipulation; }
/* ── Layout ── */ /* ── Layout ── */
.app { max-width: 980px; padding: 0 1rem 4rem; } .app { max-width: 980px; padding: 0 var(--gutter) 4rem; }
@media (min-width: 900px) { .app { padding: 0 1.5rem 4rem; } } @media (min-width: 900px) { :root { --gutter: 1.5rem; } }
/* ── Slim top bar ── */ /* ── Slim top bar ── */
.topbar { .topbar {
position: sticky; top: 0; z-index: 200; position: sticky; top: 0; z-index: 200;
display: flex; align-items: center; gap: 0.75rem; display: flex; align-items: center; gap: 0.75rem;
margin: 0 -1rem 1rem; padding: calc(0.55rem + env(safe-area-inset-top, 0px)) calc(1rem + env(safe-area-inset-right, 0px)) 0.55rem calc(1rem + env(safe-area-inset-left, 0px)); margin: 0 calc(-1 * var(--gutter)) 1rem; padding: calc(0.55rem + env(safe-area-inset-top, 0px)) calc(var(--gutter) + env(safe-area-inset-right, 0px)) 0.55rem calc(var(--gutter) + env(safe-area-inset-left, 0px));
min-height: var(--topbar-h); min-height: var(--topbar-h);
background: color-mix(in srgb, var(--paper) 86%, transparent); background: color-mix(in srgb, var(--paper) 86%, transparent);
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
@media (min-width: 900px) { .topbar { margin: 0 -1.5rem 1.25rem; padding-left: 1.5rem; padding-right: 1.5rem; } } @media (min-width: 900px) { .topbar { margin-bottom: 1.25rem; } }
.brand { display: inline-flex; align-items: center; gap: 0.4rem; text-decoration: none; color: var(--ink); font-weight: 800; letter-spacing: -0.01em; font-size: 1.05rem; flex-shrink: 0; } .brand { display: inline-flex; align-items: center; gap: 0.4rem; text-decoration: none; color: var(--ink); font-weight: 800; letter-spacing: -0.01em; font-size: 1.05rem; flex-shrink: 0; }
.brand-mark { color: var(--accent); font-size: 1.3rem; line-height: 1; } .brand-mark { color: var(--accent); font-size: 1.3rem; line-height: 1; }
.topbar .tabs { border: none; border-radius: 999px; background: var(--paper-dark); padding: 3px; gap: 2px; margin: 0 auto; overflow: visible; } .topbar .tabs { border: none; border-radius: 999px; background: var(--paper-dark); padding: 3px; gap: 2px; margin: 0 auto; overflow: visible; }
@@ -47,8 +48,9 @@ button, a, summary { touch-action: manipulation; }
.icon-btn:hover { color: var(--accent); border-color: var(--accent); } .icon-btn:hover { color: var(--accent); border-color: var(--accent); }
.icon-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; } .icon-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
@media (max-width: 640px) { @media (max-width: 640px) {
.app { padding: 0 0.9rem max(5.5rem, calc(5rem + env(safe-area-inset-bottom, 0px))); } :root { --gutter: 0.9rem; }
.topbar { margin: 0 -0.9rem 0.8rem; } .app { padding: 0 calc(var(--gutter) + env(safe-area-inset-right, 0px)) max(5.5rem, calc(5rem + env(safe-area-inset-bottom, 0px))) calc(var(--gutter) + env(safe-area-inset-left, 0px)); }
.topbar { margin: 0 calc(-1 * (var(--gutter) + env(safe-area-inset-right, 0px))) 0.8rem calc(-1 * (var(--gutter) + env(safe-area-inset-left, 0px))); }
.brand-name { display: none; } .brand-name { display: none; }
.topbar .tabs { display: none; } .topbar .tabs { display: none; }
.loc-chip { flex: 1; max-width: none; justify-content: flex-start; } .loc-chip { flex: 1; max-width: none; justify-content: flex-start; }
@@ -130,7 +132,7 @@ button, a, summary { touch-action: manipulation; }
.bring-row .rec-label { font-size: 0.78rem; font-weight: 600; white-space: nowrap; } .bring-row .rec-label { font-size: 0.78rem; font-weight: 600; white-space: nowrap; }
/* ── Sticky section headings (iPhone friendly) ── */ /* ── Sticky section headings (iPhone friendly) ── */
.sticky-head { position: sticky; top: calc(var(--topbar-h) + env(safe-area-inset-top, 0px) - 6px); z-index: 20; padding: 0.45rem 0.15rem; margin: 0 0 0.5rem; .sticky-head { position: sticky; top: calc(var(--topbar-h) - 6px); z-index: 20; padding: 0.45rem 0.15rem; margin: 0 0 0.5rem;
background: color-mix(in srgb, var(--paper) 90%, transparent); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); } background: color-mix(in srgb, var(--paper) 90%, transparent); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); }
.section-label { font-size: 0.78rem; letter-spacing: 0.12em; } .section-label { font-size: 0.78rem; letter-spacing: 0.12em; }
.link-btn { min-height: 36px; font-style: normal; color: var(--accent); } .link-btn { min-height: 36px; font-style: normal; color: var(--accent); }
@@ -208,3 +210,34 @@ body:not([data-tab="conditions"]) #loading { display: none; }
.hilo-arrow { display: inline; font-size: 0.8em; opacity: .85; margin-right: 1px; } .hilo-arrow { display: inline; font-size: 0.8em; opacity: .85; margin-right: 1px; }
.hilo-label { flex-basis: 100%; font-size: 0.62rem; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; opacity: .85; margin-top: 2px; } .hilo-label { flex-basis: 100%; font-size: 0.62rem; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; opacity: .85; margin-top: 2px; }
@media (max-width: 640px) { .hero-hilo { flex-direction: column; gap: 0.35rem; } .hilo-divider { width: 70%; height: 1px; } } @media (max-width: 640px) { .hero-hilo { flex-direction: column; gap: 0.35rem; } .hilo-divider { width: 70%; height: 1px; } }
/* ── Install guide ── */
.install-banner { display: flex; align-items: center; gap: 0.7rem; margin: 0 0 0.9rem; padding: 0.65rem 0.8rem; border-radius: 16px;
background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 18%, var(--card-bg)), var(--card-bg)); border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--border)); }
.install-banner.hidden { display: none; }
.ib-ico { font-size: 1.6rem; }
.ib-text { flex: 1; min-width: 0; display: flex; flex-direction: column; font-size: 0.85rem; line-height: 1.25; }
.ib-text span { font-size: 0.74rem; color: var(--ink-light); }
.ib-btn { font: inherit; font-weight: 700; font-size: 0.82rem; min-height: 40px; padding: 0 1rem; border-radius: 999px; border: none; background: var(--accent); color: #fff; cursor: pointer; }
.ib-x { font: inherit; width: 36px; height: 36px; border: none; background: transparent; color: var(--ink-light); font-size: 1rem; cursor: pointer; border-radius: 50%; }
.install-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 16px; padding: 1rem 1.1rem; margin-bottom: 1.4rem; }
.ins-lead { font-size: 0.88rem; margin-bottom: 0.8rem; color: var(--ink-mid); }
.ins-done { font-weight: 700; color: #22c55e; margin-bottom: 0.4rem; }
.ins-tabs { display: inline-flex; padding: 3px; gap: 2px; border-radius: 999px; background: var(--paper-darker); margin-bottom: 0.8rem; }
.ins-tab { font: inherit; font-size: 0.8rem; font-weight: 700; min-height: 36px; padding: 0 0.9rem; border: none; border-radius: 999px; background: transparent; color: var(--ink-light); cursor: pointer; }
.ins-tab.on { background: var(--accent); color: #fff; }
.ins-steps { margin: 0 0 0.6rem 1.2rem; display: grid; gap: 0.55rem; font-size: 0.9rem; line-height: 1.4; }
.ins-steps li::marker { font-weight: 800; color: var(--accent); }
.ins-ico { vertical-align: -5px; margin: 0 2px; color: var(--accent); }
.ins-note { font-size: 0.78rem; color: var(--ink-light); line-height: 1.5; margin-top: 0.5rem; }
.ins-install { font: inherit; font-weight: 700; min-height: 44px; padding: 0 1.4rem; margin-bottom: 0.8rem; border: none; border-radius: 999px; background: var(--accent); color: #fff; cursor: pointer; }
.install-dialog { border: 1px solid var(--border); border-radius: 20px; background: var(--paper-dark); color: var(--ink); padding: 1.1rem 1.2rem 1.3rem; width: min(440px, 92vw); margin: auto; }
.install-dialog::backdrop { background: rgba(0,0,0,.6); }
.install-dialog-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.7rem; }
.install-dialog-head h2 { font-size: 1.1rem; }
.install-dialog-head button { font: inherit; width: 36px; height: 36px; border: none; background: transparent; color: var(--ink-light); font-size: 1rem; cursor: pointer; border-radius: 50%; }
/* Landscape phones: side insets for the content column (the top bar already adds its own) */
.app { padding-left: calc(var(--gutter) + env(safe-area-inset-left, 0px)); padding-right: calc(var(--gutter) + env(safe-area-inset-right, 0px)); }
.topbar { margin-left: calc(-1 * (var(--gutter) + env(safe-area-inset-left, 0px))); margin-right: calc(-1 * (var(--gutter) + env(safe-area-inset-right, 0px))); }
.install-dialog { margin-top: max(1rem, env(safe-area-inset-top, 0px)); }
+99
View File
@@ -23,6 +23,12 @@ if (typeof baseActivate === 'function') {
}; };
} }
// Keep sticky section headings flush under the (safe-area aware) top bar
const topbarEl = document.querySelector('.topbar');
if (topbarEl && 'ResizeObserver' in window) {
new ResizeObserver(() => document.documentElement.style.setProperty('--topbar-h', topbarEl.offsetHeight + 'px')).observe(topbarEl);
}
// ── Top bar: location chip opens search ────────────────────────────────────── // ── Top bar: location chip opens search ──────────────────────────────────────
$('tb-search')?.addEventListener('click', () => { $('tb-search')?.addEventListener('click', () => {
window.activateTab('settings'); window.activateTab('settings');
@@ -398,6 +404,99 @@ window.AppUI = {
onStation() { syncTopbarLocation(); renderStationContext(); }, onStation() { syncTopbarLocation(); renderStationContext(); },
}; };
// ── Install as an app (PWA): guidance for iPhone/iPad and Android ────────────
const Install = (() => {
const ua = navigator.userAgent || '';
const isIOS = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const isAndroid = /Android/i.test(ua);
const standalone = () => window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true;
let deferred = null; // Chrome/Edge/Samsung install prompt
window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferred = e; refresh(); });
window.addEventListener('appinstalled', () => { deferred = null; localStorage.setItem('installDone', '1'); refresh(); });
const SHARE = '<svg class="ins-ico" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M12 3v12M12 3 8 7M12 3l4 4M6 11H5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-8a1 1 0 0 0-1-1h-1" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const DOTS = '<svg class="ins-ico" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><circle cx="12" cy="5" r="2" fill="currentColor"/><circle cx="12" cy="12" r="2" fill="currentColor"/><circle cx="12" cy="19" r="2" fill="currentColor"/></svg>';
const steps = {
ios: `<ol class="ins-steps">
<li>Open this page in <b>Safari</b>. (On iOS 16.4+ Chrome and Edge can do it too.)</li>
<li>Tap the <b>Share</b> button ${SHARE} at the bottom of the screen (top right on iPad).</li>
<li>Scroll down and tap <b>Add to Home Screen</b>.</li>
<li>Tap <b>Add</b>. The app icon appears on your Home Screen.</li>
</ol>
<p class="ins-note">Open it from the Home Screen icon for the full-screen app. iPhone has no automatic install pop-up, so this manual step is the only way.</p>`,
android: `<ol class="ins-steps">
<li>Open this page in <b>Chrome</b>.</li>
<li>Tap the <b>menu</b> ${DOTS} (top right).</li>
<li>Tap <b>Install app</b> (or <b>Add to Home screen</b>).</li>
<li>Tap <b>Install</b>. The app shows up in your app drawer and on your Home screen.</li>
</ol>
<p class="ins-note"><b>Samsung Internet:</b> menu (☰) → <b>Add page to</b> → <b>Home screen</b>. <b>Firefox:</b> menu → <b>Install</b>.</p>`,
};
const other = '<p class="ins-note">Desktop Chrome/Edge: click the install icon in the address bar (or menu → <b>Install NeCloud Weather</b>).</p>';
const dashNote = '<p class="ins-note">The <b>Station Dashboard</b> installs separately: open <b>/dashboard</b> and follow the same steps to get its own icon.</p>';
function platform() { return isIOS ? 'ios' : isAndroid ? 'android' : null; }
function body(active) {
const p = active || platform() || 'ios';
const tabs = `<div class="ins-tabs" role="tablist">
<button type="button" role="tab" class="ins-tab${p === 'ios' ? ' on' : ''}" data-p="ios">iPhone / iPad</button>
<button type="button" role="tab" class="ins-tab${p === 'android' ? ' on' : ''}" data-p="android">Android</button></div>`;
const promptBtn = deferred && p === 'android' ? '<button type="button" class="ins-install" id="ins-native">Install now</button>' : '';
return `${tabs}<div class="ins-body" data-p="${p}">${promptBtn}${steps[p]}${!platform() ? other : ''}${dashNote}</div>`;
}
function wire(root) {
root.querySelectorAll('.ins-tab').forEach((b) => b.addEventListener('click', () => { root.innerHTML = body(b.dataset.p); wire(root); }));
root.querySelector('#ins-native')?.addEventListener('click', async () => {
if (!deferred) return;
deferred.prompt(); try { await deferred.userChoice; } catch {} deferred = null; refresh();
});
}
function renderCard() {
const card = $('install-card');
if (!card) return;
if (standalone()) {
card.innerHTML = '<p class="ins-done">✓ You\'re using the installed app.</p>' + dashNote;
return;
}
card.innerHTML = `<p class="ins-lead">Add NeCloud Weather to your phone like a normal app: full screen, its own icon, and it opens instantly.</p><div class="ins-wrap">${body()}</div>`;
wire(card.querySelector('.ins-wrap'));
}
// Banner: phones only, not installed, not dismissed within 30 days
function renderBanner() {
const el = $('install-banner');
if (!el) return;
let dismissed = 0; try { dismissed = +localStorage.getItem('installDismissed') || 0; } catch {}
const show = !standalone() && (isIOS || isAndroid) && Date.now() - dismissed > 30 * 86400000 && !localStorage.getItem('installDone');
el.classList.toggle('hidden', !show);
$('ib-how').textContent = deferred ? 'Install' : 'How';
}
function openDialog() {
const dlg = $('install-dialog'), b = $('install-dialog-body');
if (!dlg || !b) return;
b.innerHTML = body(); wire(b);
if (typeof dlg.showModal === 'function') dlg.showModal(); else dlg.setAttribute('open', '');
}
function refresh() { renderBanner(); renderCard(); }
$('ib-how')?.addEventListener('click', async () => {
if (deferred) { deferred.prompt(); try { await deferred.userChoice; } catch {} deferred = null; refresh(); }
else openDialog();
});
$('ib-close')?.addEventListener('click', () => { try { localStorage.setItem('installDismissed', String(Date.now())); } catch {} $('install-banner').classList.add('hidden'); });
$('install-dialog-close')?.addEventListener('click', () => $('install-dialog').close());
$('install-dialog')?.addEventListener('click', (e) => { if (e.target === e.currentTarget) e.currentTarget.close(); });
refresh();
return { refresh, openDialog };
})();
syncTopbarLocation(); syncTopbarLocation();
new MutationObserver(syncTopbarLocation).observe($('loc-name') || document.body, { childList: true, characterData: true, subtree: true }); new MutationObserver(syncTopbarLocation).observe($('loc-name') || document.body, { childList: true, characterData: true, subtree: true });
})(); })();
+16
View File
@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#070c14"> <meta name="theme-color" content="#070c14">
<link rel="manifest" href="/dashboard.webmanifest"> <link rel="manifest" href="/dashboard.webmanifest">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="/pwa-safe.css">
<title>Account</title> <title>Account</title>
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -77,6 +80,16 @@ dialog .row { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top:
<p class="hint">Changing your password signs you out everywhere else.</p> <p class="hint">Changing your password signs you out everywhere else.</p>
</div> </div>
<div class="section" id="installSection">
<div class="section-hdr">Install this dashboard as an app</div>
<p class="hint" style="margin:0 0 0.7rem" id="installDone" hidden>✓ You're using the installed app.</p>
<div id="installSteps">
<p class="hint" style="margin:0 0 0.4rem"><b>iPhone / iPad (Safari):</b> tap the <b>Share</b> button, then <b>Add to Home Screen</b>, then <b>Add</b>.</p>
<p class="hint" style="margin:0"><b>Android (Chrome):</b> tap the <b>⋮ menu</b>, then <b>Install app</b> (or <b>Add to Home screen</b>), then <b>Install</b>.</p>
<p class="hint">Open the app from its Home Screen icon for full screen. The weather app at <b>/</b> installs separately the same way.</p>
</div>
</div>
<div id="adminArea" class="hidden"> <div id="adminArea" class="hidden">
<div class="section"> <div class="section">
<div class="section-hdr"><span>Users</span><span id="userCount" class="hint" style="margin:0"></span></div> <div class="section-hdr"><span>Users</span><span id="userCount" class="hint" style="margin:0"></span></div>
@@ -188,6 +201,9 @@ $('nuBtn').addEventListener('click', async () => {
} catch (e) { flash($('nuMsg'), e.message, true); } } catch (e) { flash($('nuMsg'), e.message, true); }
}); });
if (window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true) {
$('installDone').hidden = false; $('installSteps').hidden = true;
}
(async () => { (async () => {
try { try {
me = await api('/api/dashboard/me'); me = await api('/api/dashboard/me');
+1
View File
@@ -9,6 +9,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="manifest" href="/dashboard.webmanifest"> <link rel="manifest" href="/dashboard.webmanifest">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<link rel="stylesheet" href="/pwa-safe.css">
<title>Station Dashboard — Sign In</title> <title>Station Dashboard — Sign In</title>
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+5 -1
View File
@@ -2,7 +2,10 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="/pwa-safe.css">
<title>Station Dashboard — Recover Access</title> <title>Station Dashboard — Recover Access</title>
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -27,6 +30,7 @@ button:hover { opacity: 0.88; }
.error { background: rgba(194,90,42,0.15); border: 1px solid var(--accent); border-radius: 8px; padding: 0.6rem 0.8rem; margin-bottom: 1rem; font-size: 0.9rem; color: var(--accent); } .error { background: rgba(194,90,42,0.15); border: 1px solid var(--accent); border-radius: 8px; padding: 0.6rem 0.8rem; margin-bottom: 1rem; font-size: 0.9rem; color: var(--accent); }
.back { display: block; text-align: center; margin-top: 1.25rem; font-size: 0.85rem; color: var(--border); text-decoration: none; } .back { display: block; text-align: center; margin-top: 1.25rem; font-size: 0.85rem; color: var(--border); text-decoration: none; }
.back:hover { color: var(--ink); } .back:hover { color: var(--ink); }
html, body { padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px); }
</style> </style>
</head> </head>
<body> <body>
+5 -2
View File
@@ -2,7 +2,10 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="/pwa-safe.css">
<title>Station Settings</title> <title>Station Settings</title>
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -21,7 +24,7 @@
} }
html, body { min-height: 100%; background: var(--paper); color: var(--ink); font-family: var(--mono); font-size: 15px; } html, body { min-height: 100%; background: var(--paper); color: var(--ink); font-family: var(--mono); font-size: 15px; }
.header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.5rem; border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--paper-dark) 82%, transparent); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); position: sticky; top: 0; z-index: 10; transition: box-shadow .25s; } .header { display: flex; align-items: center; justify-content: space-between; padding: calc(1rem + env(safe-area-inset-top, 0px)) calc(1.5rem + env(safe-area-inset-right, 0px)) 1rem calc(1.5rem + env(safe-area-inset-left, 0px)); border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--paper-dark) 82%, transparent); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); position: sticky; top: 0; z-index: 10; transition: box-shadow .25s; }
.header.scrolled { box-shadow: 0 4px 24px rgba(0,0,0,0.18); } .header.scrolled { box-shadow: 0 4px 24px rgba(0,0,0,0.18); }
.header h1 { font-size: 1.15rem; } .header h1 { font-size: 1.15rem; }
.header-right { display: flex; align-items: center; gap: 1rem; font-size: 0.85rem; } .header-right { display: flex; align-items: center; gap: 1rem; font-size: 0.85rem; }
+5 -1
View File
@@ -2,7 +2,10 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="/pwa-safe.css">
<title>Station Dashboard — Create Account</title> <title>Station Dashboard — Create Account</title>
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -24,6 +27,7 @@ button { width: 100%; padding: 0.7rem; background: var(--accent); color: #fff; b
button:hover { opacity: 0.88; } button:hover { opacity: 0.88; }
.error { background: rgba(194,90,42,0.15); border: 1px solid var(--accent); border-radius: 8px; padding: 0.6rem 0.8rem; margin-bottom: 1rem; font-size: 0.9rem; color: var(--accent); } .error { background: rgba(194,90,42,0.15); border: 1px solid var(--accent); border-radius: 8px; padding: 0.6rem 0.8rem; margin-bottom: 1rem; font-size: 0.9rem; color: var(--accent); }
.note { margin-top: 1rem; font-size: 0.8rem; opacity: 0.55; text-align: center; } .note { margin-top: 1rem; font-size: 0.8rem; opacity: 0.55; text-align: center; }
html, body { padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px); }
</style> </style>
</head> </head>
<body> <body>
+1
View File
@@ -11,6 +11,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="manifest" href="/dashboard.webmanifest"> <link rel="manifest" href="/dashboard.webmanifest">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<link rel="stylesheet" href="/pwa-safe.css">
<title>Station Dashboard</title> <title>Station Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8/hammer.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8/hammer.min.js"></script>
+18 -1
View File
@@ -3,9 +3,10 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<link rel="stylesheet" href="/pwa-safe.css">
<title>NeCloud Weather</title> <title>NeCloud Weather</title>
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" href="/icons/icon.svg"> <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta name="theme-color" content="#edf2f7" media="(prefers-color-scheme: light)"> <meta name="theme-color" content="#edf2f7" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#070c14" media="(prefers-color-scheme: dark)"> <meta name="theme-color" content="#070c14" media="(prefers-color-scheme: dark)">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
@@ -41,6 +42,14 @@
</div> </div>
</header> </header>
<!-- Install-as-app prompt (phones, not yet installed, dismissible) -->
<div id="install-banner" class="install-banner hidden" role="region" aria-label="Install the app">
<span class="ib-ico" aria-hidden="true">📲</span>
<div class="ib-text"><b>Install NeCloud Weather</b><span>Add it to your Home Screen for a full-screen app.</span></div>
<button type="button" class="ib-btn" id="ib-how">How</button>
<button type="button" class="ib-x" id="ib-close" aria-label="Dismiss">✕</button>
</div>
<!-- Loading: skeleton of the Now tab --> <!-- Loading: skeleton of the Now tab -->
<div id="loading" class="loading hidden" aria-busy="true" aria-label="Loading weather"> <div id="loading" class="loading hidden" aria-busy="true" aria-label="Loading weather">
<div class="sk sk-hero"></div> <div class="sk sk-hero"></div>
@@ -416,6 +425,9 @@
<div id="tab-settings" class="tab-panel hidden"> <div id="tab-settings" class="tab-panel hidden">
<div class="settings-wrap"> <div class="settings-wrap">
<div class="settings-section-label">Install the app</div>
<div id="install-card" class="install-card"></div>
<div class="settings-section-label">Station</div> <div class="settings-section-label">Station</div>
<div class="settings-nav-links"> <div class="settings-nav-links">
<a href="/?tab=station" class="settings-nav-link">Live Station</a> <a href="/?tab=station" class="settings-nav-link">Live Station</a>
@@ -1277,6 +1289,11 @@
</button> </button>
</nav> </nav>
<dialog id="install-dialog" class="install-dialog" aria-label="How to install the app">
<div class="install-dialog-head"><h2>Install the app</h2><button type="button" id="install-dialog-close" aria-label="Close">✕</button></div>
<div id="install-dialog-body"></div>
</dialog>
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script> <script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
<script src="station-live.js"></script> <script src="station-live.js"></script>
+30
View File
@@ -0,0 +1,30 @@
/* Shared PWA safe-area rules: notch / Dynamic Island, rounded corners, home indicator (iPhone 14–17 etc.).
Needs <meta name="viewport" content="..., viewport-fit=cover"> on the page. */
/* Installed iPhone apps draw under the status bar with white text (black-translucent).
A dark cap behind that zone keeps the clock and battery readable on light themes too. */
@media (display-mode: standalone) {
body::before {
content: ''; position: fixed; top: 0; left: 0; right: 0; z-index: 100000; pointer-events: none;
height: env(safe-area-inset-top, 0px); background: #070c14;
}
}
/* Never let page content slide under the sensor housing or rounded corners in landscape */
/* (main app and dashboard add side insets to their own containers; simple pages use .safe-sides) */
.safe-sides { padding-left: env(safe-area-inset-left, 0px); padding-right: env(safe-area-inset-right, 0px); }
/* Fixed overlays that hug the top / sides / bottom */
.offline-banner { padding-top: calc(0.4rem + env(safe-area-inset-top, 0px)); }
.ptr-indicator { top: env(safe-area-inset-top, 0px); }
.learn-back-top { right: max(1rem, calc(0.6rem + env(safe-area-inset-right, 0px))); }
/* Bottom bars: clear the home indicator and the rounded bottom corners */
.bottom-nav { padding-left: max(8px, env(safe-area-inset-left, 0px)); padding-right: max(8px, env(safe-area-inset-right, 0px)); padding-bottom: max(6px, env(safe-area-inset-bottom, 0px)); }
/* Full-screen map: keep controls off the notch, corners and home indicator */
body.map-fs #tab-unified-map { padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px); }
body.map-fs .unified-map-wrap { height: calc(100dvh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)); }
/* Modal dialogs: stay inside the safe area on short (landscape) screens */
dialog { max-height: calc(100dvh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) - 1.5rem); overflow: auto; }
+2 -1
View File
@@ -4,7 +4,7 @@ self.addEventListener('message', event => {
if (event.data === 'skipWaiting') self.skipWaiting(); if (event.data === 'skipWaiting') self.skipWaiting();
}); });
const CACHE = 'weather-v9'; const CACHE = 'weather-v11';
// App shell cached on install for offline resilience. // App shell cached on install for offline resilience.
const PRECACHE = [ const PRECACHE = [
@@ -14,6 +14,7 @@ const PRECACHE = [
'/station-live.css', '/station-live.css',
'/station-live.js', '/station-live.js',
'/app-ui.css', '/app-ui.css',
'/pwa-safe.css',
'/app-ui.js', '/app-ui.js',
'/manifest.json', '/manifest.json',
'/icons/icon.svg' '/icons/icon.svg'