Add PWA support and expandable draw canvas

PWA:
- manifest.json with name, icons, theme/background color, standalone display
- PNG icons (192, 512, 180 apple-touch) generated via pure Node.js (no deps)
- index.html: manifest link, theme-color, apple-mobile-web-app-* meta tags,
  viewport-fit=cover for iPhone notch/Dynamic Island
- service worker extended to cache app shell (static assets + index.html)
  with network-first for navigation, cache-first for hashed assets,
  network-first-with-cache-fallback for external Bible APIs

Expandable canvas:
- "Add More Space" button below each draw canvas adds a full notebook page
- Existing ink rescales to preserve visual pixel positions (no stretching)
- Page count embedded as metadata in the strokes array so it survives
  navigation without a separate state key

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-08-12 11:28:23 -04:00
parent a947b128b9
commit 692595c629
8 changed files with 298 additions and 50 deletions
+115
View File
@@ -0,0 +1,115 @@
// Generates PWA icon PNGs using only Node.js built-ins (no dependencies).
import { deflateSync } from 'zlib';
import { writeFileSync, mkdirSync } from 'fs';
function crc32(buf) {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
table[i] = c;
}
let crc = 0xffffffff;
for (const b of buf) crc = table[(crc ^ b) & 0xff] ^ (crc >>> 8);
return ((crc ^ 0xffffffff) >>> 0);
}
function u32(n) {
const b = Buffer.alloc(4);
b.writeUInt32BE(n, 0);
return b;
}
function chunk(type, data) {
const t = Buffer.from(type, 'ascii');
return Buffer.concat([u32(data.length), t, data, u32(crc32(Buffer.concat([t, data])))]);
}
function makePNG(size, draw) {
const px = new Uint8ClampedArray(size * size * 4); // RGBA
draw(px, size);
const rows = [];
for (let y = 0; y < size; y++) {
rows.push(0); // PNG filter byte: None
for (let x = 0; x < size; x++) {
const i = (y * size + x) * 4;
rows.push(px[i], px[i + 1], px[i + 2], px[i + 3]);
}
}
const raw = Buffer.from(rows);
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const ihdr = chunk('IHDR', Buffer.concat([u32(size), u32(size), Buffer.from([8, 6, 0, 0, 0])]));
const idat = chunk('IDAT', deflateSync(raw, { level: 9 }));
const iend = chunk('IEND', Buffer.alloc(0));
return Buffer.concat([sig, ihdr, idat, iend]);
}
function setPixel(px, size, x, y, r, g, b, a = 255) {
if (x < 0 || x >= size || y < 0 || y >= size) return;
const i = (y * size + x) * 4;
px[i] = r; px[i + 1] = g; px[i + 2] = b; px[i + 3] = a;
}
function fillRect(px, size, x1, y1, x2, y2, r, g, b, a = 255) {
for (let y = Math.max(0, y1); y < Math.min(size, y2); y++)
for (let x = Math.max(0, x1); x < Math.min(size, x2); x++)
setPixel(px, size, x, y, r, g, b, a);
}
function drawIcon(px, S) {
const rad = Math.round(S * 0.22); // corner radius
// Rounded background: #0f172a (15, 23, 42)
for (let y = 0; y < S; y++) {
for (let x = 0; x < S; x++) {
const cx = Math.min(x, S - 1 - x);
const cy = Math.min(y, S - 1 - y);
if (cx < rad && cy < rad) {
const dx = rad - cx - 1;
const dy = rad - cy - 1;
if (dx * dx + dy * dy > rad * rad) { setPixel(px, S, x, y, 0, 0, 0, 0); continue; }
}
setPixel(px, S, x, y, 15, 23, 42);
}
}
// Cross: vertical bar (center-ish, top-of-cross higher than center)
const cw = Math.round(S * 0.12); // cross bar thickness
const cx = Math.round(S / 2 - cw / 2);
const vTop = Math.round(S * 0.18);
const vBot = Math.round(S * 0.82);
fillRect(px, S, cx, vTop, cx + cw, vBot, 255, 255, 255);
// Horizontal bar (slightly above center)
const hh = Math.round(S * 0.12);
const hy = Math.round(S * 0.36 - hh / 2);
const hLeft = Math.round(S * 0.22);
const hRight = Math.round(S * 0.78);
fillRect(px, S, hLeft, hy, hRight, hy + hh, 255, 255, 255);
// Subtle glow/shine at cross intersection (slightly lighter center)
const glowR = Math.round(S * 0.07);
const gcx = Math.round(S / 2);
const gcy = Math.round(S * 0.36);
for (let y = gcy - glowR; y <= gcy + glowR; y++) {
for (let x = gcx - glowR; x <= gcx + glowR; x++) {
const d2 = (x - gcx) ** 2 + (y - gcy) ** 2;
if (d2 <= glowR * glowR) {
const i = (Math.max(0, Math.min(S - 1, y)) * S + Math.max(0, Math.min(S - 1, x))) * 4;
if (px[i + 3] === 255) {
px[i] = Math.min(255, px[i] + 20);
px[i + 1] = Math.min(255, px[i + 1] + 20);
px[i + 2] = Math.min(255, px[i + 2] + 20);
}
}
}
}
}
mkdirSync('public', { recursive: true });
writeFileSync('public/icon-192.png', makePNG(192, drawIcon));
writeFileSync('public/icon-512.png', makePNG(512, drawIcon));
writeFileSync('public/apple-touch-icon.png', makePNG(180, drawIcon));
console.log('Icons written to public/');