add PWA (progressive web app) option
@@ -48,6 +48,10 @@ ```
Then open <http://localhost:8080>. +### Install on Android + +Serve Study Lab over HTTPS (or use `localhost` while testing), open it in Chrome on Android, and choose **Install app** when the button appears. You can also use Chrome's menu and choose **Install app** or **Add to Home screen**. The installed app opens in its own window and caches its local interface for offline startup; sending AI requests still requires an internet connection. + Alternatively, with Node.js: ```bash
@@ -82,6 +82,7 @@ let searchIndex = null;
let pendingG = false; let pendingGTimer = 0; let treeZoom = loadTreeZoom(); + let installPrompt = null; function loadJSON(key, fallback) { try { return JSON.parse(localStorage.getItem(key) || 'null') || fallback; }@@ -1564,6 +1565,33 @@ $('#collapse-map').addEventListener('click', () => { document.body.classList.remove('map-open'); document.body.classList.add('map-collapsed'); $('#expand-map').hidden = false; });
$('#expand-map').addEventListener('click', () => { document.body.classList.remove('map-collapsed', 'history-open'); document.body.classList.add('map-open'); $('#expand-map').hidden = true; updateHistoryToggle(); }); addEventListener('resize', updateHistoryToggle, { passive: true }); document.addEventListener('keydown', handleVimNavigation); + + const installButton = $('#install-app'); + window.addEventListener('beforeinstallprompt', event => { + event.preventDefault(); + installPrompt = event; + installButton.hidden = false; + }); + window.addEventListener('appinstalled', () => { + installPrompt = null; + installButton.hidden = true; + showToast('Study Lab installed'); + }); + installButton.addEventListener('click', async () => { + if (!installPrompt) return; + installPrompt.prompt(); + await installPrompt.userChoice; + installPrompt = null; + installButton.hidden = true; + }); + + if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('./service-worker.js').catch(error => { + console.warn('Study Lab service worker registration failed:', error); + }); + }); + } updateKeyStatus(); updateThemeButton();
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"> + <rect width="512" height="512" fill="#4f6d60"/> + <path d="M256 112c68 0 130 57 130 142 0 87-59 155-138 155-80 0-127-65-124-147 3-85 59-150 132-150Z" fill="#18201d"/> + <text x="255" y="337" fill="#f8f7f2" font-family="Georgia, 'Times New Roman', serif" font-size="220" font-style="italic" text-anchor="middle">S</text> + <circle cx="361" cy="158" r="25" fill="#e98557"/> +</svg>
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"> + <rect width="512" height="512" rx="112" fill="#f8f7f2"/> + <path d="M256 76c83 0 158 70 158 173 0 105-72 188-168 188-98 0-155-79-151-179C99 155 167 76 256 76Z" fill="#18201d"/> + <text x="255" y="349" fill="#f8f7f2" font-family="Georgia, 'Times New Roman', serif" font-size="264" font-style="italic" text-anchor="middle">S</text> + <circle cx="384" cy="132" r="31" fill="#e98557"/> +</svg>
@@ -4,6 +4,9 @@ <head>
<meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="color-scheme" content="light dark"> + <meta name="theme-color" content="#f8f7f2" media="(prefers-color-scheme: light)"> + <meta name="theme-color" content="#151a18" media="(prefers-color-scheme: dark)"> + <meta name="description" content="A local-first, branching AI study space for following every thought."> <title>Study Lab — Branch your thinking</title> <script> try {@@ -16,6 +19,9 @@ (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
} catch { document.documentElement.dataset.theme = 'light'; } </script> <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin> + <link rel="manifest" href="manifest.webmanifest"> + <link rel="icon" href="icons/icon-192.png" sizes="192x192" type="image/png"> + <link rel="apple-touch-icon" href="icons/apple-touch-icon.png"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css"> <link rel="stylesheet" href="styles.css"> </head>@@ -29,6 +35,7 @@ <span>Study Lab</span>
</a> </div> <div class="top-actions"> + <button id="install-app" class="install-button" type="button" hidden>Install app</button> <a class="github-button" href="https://github.com/arnaucube/study-lab" target="_blank" rel="noopener noreferrer" aria-label="Open Study Lab on GitHub" title="View source on GitHub"> <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .7a11.5 11.5 0 0 0-3.64 22.41c.58.1.79-.25.79-.56v-2.23c-3.22.7-3.9-1.37-3.9-1.37-.53-1.34-1.29-1.7-1.29-1.7-1.05-.72.08-.71.08-.71 1.16.08 1.78 1.2 1.78 1.2 1.04 1.77 2.72 1.26 3.38.96.1-.75.4-1.26.74-1.55-2.57-.29-5.27-1.28-5.27-5.68 0-1.26.45-2.28 1.19-3.09-.12-.29-.52-1.46.11-3.05 0 0 .97-.31 3.16 1.18a10.96 10.96 0 0 1 5.76 0c2.19-1.49 3.16-1.18 3.16-1.18.63 1.59.23 2.76.11 3.05.74.81 1.19 1.83 1.19 3.09 0 4.41-2.71 5.38-5.29 5.67.42.36.79 1.06.79 2.14v3.17c0 .31.21.67.8.56A11.5 11.5 0 0 0 12 .7Z"/></svg> </a>
@@ -0,0 +1,32 @@
+{ + "id": "./", + "name": "Study Lab", + "short_name": "Study Lab", + "description": "A local-first, branching AI study space for following every thought.", + "start_url": "./", + "scope": "./", + "display": "standalone", + "background_color": "#f8f7f2", + "theme_color": "#4f6d60", + "orientation": "any", + "icons": [ + { + "src": "icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icons/icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +}
@@ -0,0 +1,106 @@
+#!/usr/bin/env node + +const fs = require('node:fs'); +const path = require('node:path'); +const zlib = require('node:zlib'); + +const root = path.resolve(__dirname, '..'); + +function crc32(buffer) { + let crc = 0xffffffff; + for (const byte of buffer) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function chunk(type, data) { + const name = Buffer.from(type); + const length = Buffer.alloc(4); + const checksum = Buffer.alloc(4); + length.writeUInt32BE(data.length); + checksum.writeUInt32BE(crc32(Buffer.concat([name, data]))); + return Buffer.concat([length, name, data, checksum]); +} + +function distanceToSegment(px, py, ax, ay, bx, by) { + const dx = bx - ax; + const dy = by - ay; + const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy))); + return Math.hypot(px - (ax + t * dx), py - (ay + t * dy)); +} + +function onStroke(x, y, points, radius) { + for (let index = 1; index < points.length; index += 1) { + if (distanceToSegment(x, y, ...points[index - 1], ...points[index]) <= radius) return true; + } + return false; +} + +function sample(x, y, maskable) { + const paper = [248, 247, 242]; + const sage = [79, 109, 96]; + const ink = [24, 32, 29]; + const orange = [233, 133, 87]; + let color = maskable ? sage : paper; + const blob = ((x - 256) / 162) ** 2 + ((y - 258) / 181) ** 2 <= 1; + if (blob) color = ink; + + const sPath = [ + [330, 171], [309, 150], [270, 143], [228, 151], [201, 175], [195, 204], + [205, 229], [235, 246], [278, 260], [310, 277], [320, 304], [314, 335], + [287, 361], [245, 369], [207, 358], [184, 335] + ]; + if (blob && onStroke(x, y, sPath, 24)) color = paper; + if ((x - 379) ** 2 + (y - 137) ** 2 <= 30 ** 2) color = orange; + return color; +} + +function render(size, maskable = false) { + const rows = []; + const samples = 2; + for (let py = 0; py < size; py += 1) { + const row = Buffer.alloc(1 + size * 4); + for (let px = 0; px < size; px += 1) { + const total = [0, 0, 0]; + for (let sy = 0; sy < samples; sy += 1) { + for (let sx = 0; sx < samples; sx += 1) { + const color = sample((px + (sx + 0.5) / samples) * 512 / size, (py + (sy + 0.5) / samples) * 512 / size, maskable); + total[0] += color[0]; total[1] += color[1]; total[2] += color[2]; + } + } + const offset = 1 + px * 4; + row[offset] = Math.round(total[0] / (samples * samples)); + row[offset + 1] = Math.round(total[1] / (samples * samples)); + row[offset + 2] = Math.round(total[2] / (samples * samples)); + row[offset + 3] = 255; + } + rows.push(row); + } + + const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + const header = Buffer.alloc(13); + header.writeUInt32BE(size, 0); + header.writeUInt32BE(size, 4); + header[8] = 8; + header[9] = 6; + const png = Buffer.concat([ + signature, + chunk('IHDR', header), + chunk('IDAT', zlib.deflateSync(Buffer.concat(rows), { level: 9 })), + chunk('IEND', Buffer.alloc(0)) + ]); + return png; +} + +const outputs = [ + ['icon-192.png', 192, false], + ['icon-512.png', 512, false], + ['icon-maskable-512.png', 512, true], + ['apple-touch-icon.png', 180, false] +]; + +for (const [filename, size, maskable] of outputs) { + fs.writeFileSync(path.join(root, 'icons', filename), render(size, maskable)); +}
@@ -0,0 +1,49 @@
+const CACHE_NAME = 'study-lab-shell-v1'; +const APP_SHELL = [ + './', + './index.html', + './styles.css', + './app.js', + './manifest.webmanifest', + './icons/icon-192.png', + './icons/icon-512.png', + './icons/icon-maskable-512.png', + './icons/apple-touch-icon.png' +]; + +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)))) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', event => { + if (event.request.method !== 'GET') return; + + const url = new URL(event.request.url); + if (url.origin !== self.location.origin) return; + + event.respondWith( + fetch(event.request) + .then(response => { + if (response.ok) { + const copy = response.clone(); + caches.open(CACHE_NAME).then(cache => cache.put(event.request, copy)); + } + return response; + }) + .catch(async () => { + const cached = await caches.match(event.request); + if (cached) return cached; + if (event.request.mode === 'navigate') return caches.match('./index.html'); + return Response.error(); + }) + ); +});
@@ -60,6 +60,8 @@ .brand-mark { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50% 48% 44% 52%; background: var(--ink); color: white; font-family: Georgia, serif; font-style: italic; }
.history-toggle { display: grid; place-items: center; width: 34px; height: 34px; border: 1px solid var(--line); border-radius: 9px; background: transparent; cursor: pointer; font-size: 1rem; } .history-toggle:hover { background: var(--card); } .top-actions { display: flex; align-items: center; gap: 8px; } +.install-button { height: 34px; padding: 0 12px; border: 0; border-radius: 999px; background: var(--sage-dark); color: #fff; cursor: pointer; font-size: .7rem; font-weight: 750; white-space: nowrap; } +.install-button:hover { filter: brightness(1.08); } .github-button { display: grid; place-items: center; width: 34px; height: 34px; border: 1px solid var(--line); border-radius: 50%; color: var(--ink); background: transparent; transition: transform .15s, background .15s; } .github-button:hover { transform: translateY(-1px); background: var(--card); } .github-button svg { width: 17px; height: 17px; fill: currentColor; }@@ -363,6 +365,8 @@ .usage-dialog form { padding: 19px; }
.usage-dialog .dialog-actions { display: grid; grid-template-columns: 1fr 1fr; } .usage-dialog .dialog-actions .button-dark { grid-column: 1 / -1; } .shortcut-list { grid-template-columns: 118px 1fr; gap: 9px 11px; } + .install-button { width: 34px; padding: 0; font-size: 0; } + .install-button::before { content: "↓"; font-size: 1rem; } } @media (max-width: 700px) { .api-key-grid, .model-fields { grid-template-columns: 1fr; gap: 0; }