feat: placeholder app icons (clock-with-target motif)
Generate the manifest/PWA + native-wrapper icons that were referenced but missing (icon-192/512 were 404). A reproducible pure-Node generator (tools/gen-icons.mjs, npm run gen:icons) draws a clock ring with amber hands + a green target dot in the app palette — no image deps, hand-rolled PNG encoder with analytic anti-aliasing. - public/icons/icon-192, -512, -180 (apple-touch); linked in index.html. - resources/icon.png (1024) + splash.png (2732) for @capacitor/assets. - tests/tools/icons.test.js ties every manifest icon to a real PNG of the declared size. 213 tests pass; icons now serve 200. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e79f643409
commit
8f807d50b5
@@ -29,8 +29,9 @@ npm run ios:add # = vite build → npx cap add ios (runs pod install)
|
||||
This creates `ios/` (an Xcode project under `ios/App`). Commit it — it is the native
|
||||
project source. Generated/derived paths inside it are git-ignored (see `.gitignore`).
|
||||
|
||||
App icons + splash (once): drop a 1024×1024 source at `resources/icon.png` and a
|
||||
splash at `resources/splash.png`, then `npx @capacitor/assets generate --ios`.
|
||||
App icons + splash (once): placeholder sources are provided at `resources/icon.png`
|
||||
(1024²) and `resources/splash.png` (2732²) — regenerate with `npm run gen:icons`, or
|
||||
replace them with real art. Then `npx @capacitor/assets generate --ios`.
|
||||
|
||||
## Every build after a web change
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
<title>The Director</title>
|
||||
<link rel="stylesheet" href="/src/styles.css" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-180.png" />
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/icons/icon-512.png" />
|
||||
<meta name="theme-color" content="#0b0e13" />
|
||||
<!-- iOS home-screen / native-wrapper hints -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"gen:scenario": "node tools/md-to-scenario.mjs",
|
||||
"gen:icons": "node tools/gen-icons.mjs",
|
||||
"ios:add": "npm run build && cap add ios",
|
||||
"ios:sync": "npm run build && cap sync ios",
|
||||
"ios:open": "cap open ios",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 234 KiB |
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
// PNG: 8-byte signature, then IHDR with width/height at bytes 16..24.
|
||||
function pngInfo(path) {
|
||||
const b = readFileSync(path);
|
||||
const isPng = b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47;
|
||||
return { isPng, width: b.readUInt32BE(16), height: b.readUInt32BE(20), bytes: b.length };
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(readFileSync('public/manifest.webmanifest', 'utf8'));
|
||||
|
||||
describe('app icons', () => {
|
||||
it('every manifest icon exists as a valid PNG of the declared size', () => {
|
||||
expect(manifest.icons.length).toBeGreaterThan(0);
|
||||
for (const icon of manifest.icons) {
|
||||
const path = 'public' + icon.src; // e.g. /icons/icon-192.png → public/icons/...
|
||||
const info = pngInfo(path);
|
||||
const [w] = icon.sizes.split('x').map(Number);
|
||||
expect(info.isPng, path).toBe(true);
|
||||
expect(info.width, path + ' width').toBe(w);
|
||||
expect(info.height, path + ' height').toBe(w);
|
||||
expect(info.bytes, path + ' non-empty').toBeGreaterThan(100);
|
||||
}
|
||||
});
|
||||
|
||||
it('ships the apple-touch icon referenced by index.html', () => {
|
||||
expect(readFileSync('index.html', 'utf8')).toContain('/icons/icon-180.png');
|
||||
expect(pngInfo('public/icons/icon-180.png').width).toBe(180);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
// Reproducible placeholder app-icon generator for The Director. Pure Node — no
|
||||
// image deps: draws a clock-with-target motif (the pacing / "you are here vs
|
||||
// target" idea) in the app palette and writes PNGs with a hand-rolled encoder.
|
||||
// node tools/gen-icons.mjs
|
||||
import { deflateSync } from 'node:zlib';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
|
||||
// ---- tiny PNG encoder (RGBA, non-interlaced) ------------------------------
|
||||
const CRC = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
t[n] = c >>> 0;
|
||||
}
|
||||
return t;
|
||||
})();
|
||||
function crc32(buf) {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) c = CRC[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
function chunk(type, data) {
|
||||
const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0);
|
||||
const t = Buffer.from(type, 'ascii');
|
||||
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(Buffer.concat([t, data])), 0);
|
||||
return Buffer.concat([len, t, data, crc]);
|
||||
}
|
||||
function encodePNG(size, rgba) {
|
||||
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(size, 0); ihdr.writeUInt32BE(size, 4);
|
||||
ihdr[8] = 8; ihdr[9] = 6; // 8-bit, RGBA
|
||||
const stride = size * 4;
|
||||
const raw = Buffer.alloc(size * (stride + 1));
|
||||
for (let y = 0; y < size; y++) rgba.copy(raw, y * (stride + 1) + 1, y * stride, y * stride + stride);
|
||||
return Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw, { level: 9 })), chunk('IEND', Buffer.alloc(0))]);
|
||||
}
|
||||
|
||||
// ---- drawing --------------------------------------------------------------
|
||||
const lerp = (a, b, t) => a + (b - a) * t;
|
||||
const ss = (e0, e1, x) => { let t = (x - e0) / (e1 - e0); t = Math.max(0, Math.min(1, t)); return t * t * (3 - 2 * t); };
|
||||
const over = (dst, src, a) => [lerp(dst[0], src[0], a), lerp(dst[1], src[1], a), lerp(dst[2], src[2], a)];
|
||||
function distSeg(px, py, ax, ay, bx, by) {
|
||||
const dx = bx - ax, dy = by - ay, l2 = dx * dx + dy * dy;
|
||||
let t = l2 ? ((px - ax) * dx + (py - ay) * dy) / l2 : 0;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
|
||||
}
|
||||
|
||||
const AMBER = [255, 212, 121], GREEN = [111, 224, 160], INK = [11, 14, 19];
|
||||
|
||||
function draw(size, motifR) {
|
||||
const rgba = Buffer.alloc(size * size * 4);
|
||||
const cx = size / 2, cy = size / 2, aa = size * 0.0035;
|
||||
const outerR = size * motifR, innerR = outerR - size * 0.05, midR = (outerR + innerR) / 2;
|
||||
const md = [Math.sin(0.349), -Math.cos(0.349)]; // minute hand ~1 o'clock (20°)
|
||||
const hd = [Math.sin(1.920), -Math.cos(1.920)]; // hour hand ~3-4 o'clock (110°)
|
||||
const mLen = outerR * 0.66, hLen = outerR * 0.46, mHalf = size * 0.018, hHalf = size * 0.024;
|
||||
const hubR = size * 0.032, targetR = size * 0.05, tx = cx, ty = cy - midR;
|
||||
|
||||
for (let y = 0; y < size; y++) {
|
||||
const t = y / (size - 1);
|
||||
const bg = [lerp(18, 13, t), lerp(23, 17, t), lerp(31, 23, t)];
|
||||
for (let x = 0; x < size; x++) {
|
||||
let c = [bg[0], bg[1], bg[2]];
|
||||
const px = x + 0.5, py = y + 0.5, dist = Math.hypot(px - cx, py - cy);
|
||||
c = over(c, AMBER, (1 - ss(outerR - aa, outerR + aa, dist)) * ss(innerR - aa, innerR + aa, dist)); // ring
|
||||
c = over(c, AMBER, 1 - ss(mHalf - aa, mHalf + aa, distSeg(px, py, cx, cy, cx + md[0] * mLen, cy + md[1] * mLen)));
|
||||
c = over(c, AMBER, 1 - ss(hHalf - aa, hHalf + aa, distSeg(px, py, cx, cy, cx + hd[0] * hLen, cy + hd[1] * hLen)));
|
||||
c = over(c, AMBER, 1 - ss(hubR - aa, hubR + aa, dist)); // hub
|
||||
const dt = Math.hypot(px - tx, py - ty);
|
||||
c = over(c, INK, 1 - ss(targetR + size * 0.014 - aa, targetR + size * 0.014 + aa, dt)); // dark halo for contrast
|
||||
c = over(c, GREEN, 1 - ss(targetR - aa, targetR + aa, dt)); // target dot
|
||||
const i = (y * size + x) * 4;
|
||||
rgba[i] = Math.round(c[0]); rgba[i + 1] = Math.round(c[1]); rgba[i + 2] = Math.round(c[2]); rgba[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
return rgba;
|
||||
}
|
||||
|
||||
mkdirSync('public/icons', { recursive: true });
|
||||
mkdirSync('resources', { recursive: true });
|
||||
|
||||
const icon = (size) => encodePNG(size, draw(size, 0.36));
|
||||
writeFileSync('public/icons/icon-192.png', icon(192));
|
||||
writeFileSync('public/icons/icon-512.png', icon(512));
|
||||
writeFileSync('public/icons/icon-180.png', icon(180)); // apple-touch-icon
|
||||
writeFileSync('resources/icon.png', icon(1024)); // Capacitor asset source
|
||||
writeFileSync('resources/splash.png', encodePNG(2732, draw(2732, 0.12))); // Capacitor splash source
|
||||
console.log('Wrote icons: 192, 512, 180, resources/icon.png (1024), resources/splash.png (2732)');
|
||||
Reference in New Issue
Block a user