feat: make roast planner mobile PWA ready
This commit is contained in:
@@ -377,6 +377,70 @@ body.show-fids .field[data-fid]::after{ opacity:1; }
|
||||
.sw-ref{ background:var(--ink-3); border-top:1px dashed var(--ink-3); background:none; height:0; border-bottom:1.5px dashed var(--ink-3); }
|
||||
.curve-wrap{ border-radius:var(--radius-md); overflow:hidden; border:1px solid var(--line); }
|
||||
|
||||
/* ─── PWA and mobile layout ───────────────────────────────────────────── */
|
||||
.connection-status{
|
||||
position:sticky; top:var(--header-h); z-index:29; padding:8px 14px;
|
||||
background:var(--warn-bg); border-bottom:1px solid #E8D49E; color:#705318;
|
||||
font-size:13px; font-weight:600; text-align:center;
|
||||
}
|
||||
.connection-status.hidden{ display:none; }
|
||||
|
||||
@media (max-width:720px){
|
||||
:root{ --header-h:auto; }
|
||||
body{ font-size:16px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.app-header{
|
||||
position:sticky; padding:10px 12px; gap:10px;
|
||||
padding-top:max(10px, env(safe-area-inset-top));
|
||||
}
|
||||
.brand{ width:100%; justify-content:space-between; }
|
||||
.brand-mark{ font-size:24px; }
|
||||
.brand-text h1{ font-size:16px; }
|
||||
.brand-sub{ font-size:12px; }
|
||||
.section-nav{ width:100%; order:2; margin:0 -2px; }
|
||||
.section-nav a{ min-height:40px; display:inline-flex; align-items:center; padding:7px 12px; }
|
||||
.header-actions{
|
||||
order:3; width:100%; display:grid; grid-template-columns:repeat(3, minmax(0, 1fr)); gap:7px;
|
||||
}
|
||||
.header-divider{ display:none; }
|
||||
.ghost-btn, .primary-btn, label.filebtn{
|
||||
min-height:44px; padding:8px 6px; font-size:12px; text-align:center;
|
||||
}
|
||||
.autosave-chip{ grid-column:1 / -1; justify-content:center; min-height:32px; margin:0; }
|
||||
.workspace-grid{ padding:14px 10px 48px; gap:14px; }
|
||||
.panel-card{ margin-bottom:14px; border-radius:12px; }
|
||||
.panel-head{ padding:14px; align-items:flex-start; }
|
||||
.panel-head h2{ font-size:16px; padding-top:4px; }
|
||||
.panel-body{ padding:14px; }
|
||||
.field-grid{ grid-template-columns:1fr; gap:12px; }
|
||||
.field-input, .text-input{ min-height:44px; font-size:16px; }
|
||||
.field-input.sm{ font-size:16px; }
|
||||
.segmented{ display:flex; width:100%; }
|
||||
.seg-opt{ flex:1 1 auto; }
|
||||
.seg-opt span{ min-height:40px; display:flex; align-items:center; justify-content:center; padding:6px 9px; text-align:center; white-space:normal; }
|
||||
.chip-group{ gap:7px; }
|
||||
.chip-opt{ flex:1 1 auto; }
|
||||
.chip-opt span{ min-height:40px; justify-content:center; width:100%; padding:7px 10px; text-align:center; }
|
||||
.blend-card{ padding:12px; gap:10px; }
|
||||
.blend-remove, .actuator-remove, .icon-btn{ min-width:44px; min-height:44px; }
|
||||
.milestone-row, .milestone-row.log-row{ padding:12px 0; }
|
||||
.milestone-plan{ min-height:40px; display:flex; align-items:center; justify-content:center; }
|
||||
.actuator-step{ gap:0; }
|
||||
.actuator-rail{ display:none; }
|
||||
.actuator-card{ padding:12px; }
|
||||
.dock-card{ padding:14px; border-radius:12px; }
|
||||
.checks-grid{ grid-template-columns:1fr; }
|
||||
.check-tile{ min-height:76px; }
|
||||
.curve-wrap{ overflow-x:auto; }
|
||||
#curve-svg-live{ min-width:520px; }
|
||||
.drawer{ width:100%; max-width:none; }
|
||||
.drawer-head{ padding:14px; }
|
||||
.drawer-body{ padding:14px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
*, *::before, *::after{ animation-duration:.01ms !important; animation-iteration-count:1 !important; scroll-behavior:auto !important; transition-duration:.01ms !important; }
|
||||
}
|
||||
|
||||
/* ─── Print: hide the screen shell, show the paper worksheet ───────────── */
|
||||
#print-sheet{ display:none; }
|
||||
.pv{ display:none; }
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="96" fill="#FBF8F4"/>
|
||||
<circle cx="256" cy="256" r="176" fill="#A8481A"/>
|
||||
<path fill="#FBF8F4" d="M166 166h180v45h-135v90h116v45H211v-90h-45z"/>
|
||||
<path fill="#FBEFE6" d="M256 118c43 54 71 91 71 133a71 71 0 1 1-142 0c0-42 28-79 71-133Z" opacity=".9"/>
|
||||
<path fill="#A8481A" d="M233 189c-13 22-20 42-20 60a43 43 0 1 0 86 0c0-18-7-38-20-60 3 10 4 19 4 28 0 23-12 42-27 42s-27-19-27-42c0-9 1-18 4-28Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 511 B |
@@ -4,6 +4,12 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Roast Planner</title>
|
||||
<meta name="theme-color" content="#A8481A">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/icon.svg">
|
||||
<link rel="stylesheet" href="/worksheet.css" media="print">
|
||||
<link rel="stylesheet" href="/app.css">
|
||||
</head>
|
||||
@@ -41,6 +47,7 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="connection-status hidden" id="connection-status" role="status" aria-live="polite"></div>
|
||||
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
|
||||
|
||||
<aside class="drawer hidden" id="panel-prefill" aria-hidden="true">
|
||||
|
||||
@@ -494,6 +494,27 @@ function wireToolbar() {
|
||||
});
|
||||
}
|
||||
|
||||
function wirePwa() {
|
||||
const status = document.getElementById("connection-status");
|
||||
const renderConnection = () => {
|
||||
const offline = !navigator.onLine;
|
||||
status.classList.toggle("hidden", !offline);
|
||||
status.textContent = offline ? "Offline — changes continue saving on this device." : "";
|
||||
};
|
||||
|
||||
window.addEventListener("online", renderConnection);
|
||||
window.addEventListener("offline", renderConnection);
|
||||
renderConnection();
|
||||
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker.register("/sw.js").catch((error) => {
|
||||
console.warn("Service worker registration failed:", error);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function wireSectionNav() {
|
||||
const links = [...document.querySelectorAll(".section-nav a")];
|
||||
const sections = links
|
||||
@@ -523,6 +544,7 @@ function init() {
|
||||
wireForm();
|
||||
wireDrawers();
|
||||
wireToolbar();
|
||||
wirePwa();
|
||||
wireSectionNav();
|
||||
initPrefillPanel({ state, renderBlendRows: renderBlend, renderActuatorRows: renderActuators, renderFormFromPlan, recompute });
|
||||
initAlogPanel({ state, recompute });
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "Roast Planner",
|
||||
"short_name": "Roast Planner",
|
||||
"description": "Plan, record, and compare coffee roasts.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#FBF8F4",
|
||||
"theme_color": "#A8481A",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
const CACHE_NAME = "roast-planner-static-v1";
|
||||
const APP_SHELL = [
|
||||
"/",
|
||||
"/index.html",
|
||||
"/app.css",
|
||||
"/manifest.webmanifest",
|
||||
"/icon.svg",
|
||||
"/js/main.js",
|
||||
"/js/prefill-ui.js",
|
||||
"/js/alog-ui.js",
|
||||
"/js/print.js",
|
||||
"/shared/fields.js",
|
||||
"/shared/ledger.js",
|
||||
"/shared/time.js",
|
||||
"/shared/reference-data.js",
|
||||
"/shared/curve.js",
|
||||
];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)));
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))),
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const { request } = event;
|
||||
let url;
|
||||
try {
|
||||
url = new URL(request.url);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (request.method !== "GET" || url.origin !== self.location.origin || url.pathname.startsWith("/api/")) return;
|
||||
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith(fetch(request).catch(() => caches.match("/index.html")));
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
caches.match(request).then((cached) => {
|
||||
const update = fetch(request)
|
||||
.then((response) => {
|
||||
if (response.ok) caches.open(CACHE_NAME).then((cache) => cache.put(request, response.clone()));
|
||||
return response;
|
||||
})
|
||||
.catch(() => cached);
|
||||
return cached ?? update;
|
||||
}),
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user