The webserver serves the repo checkout directly as static files, so index.html at the repo root must be the production build (pointing at hashed /assets/*.js) rather than the dev template (/src/main.tsx), which was being served with an unrecognized application/octet-stream MIME type and blocked by the browser. - Move src/, public/, scripts/, server/, package.json, vite.config.ts, tsconfig.json, .env(.example), and the dev index.html into app/. - Add app/scripts/deploy.mjs: copies app/dist/* into the repo root after every `vite build`, cleaning up stale hashed filenames. - Wire it into `npm run build` (app/package.json). - Update run.sh to operate from app/, and .gitignore for the new layout. - Commit the current build output (index.html, assets/, manifest, sw.js, workbox-*.js, favicon.svg, icons/, LICENSE) to the repo root. - Update README with the new project layout and deployment model.
214 lines
7.2 KiB
JavaScript
214 lines
7.2 KiB
JavaScript
// Generates the PWA icon set with no external dependencies.
|
|
// A tiny, correct PNG encoder (RGBA, filter 0) built on Node's zlib.
|
|
//
|
|
// node scripts/gen-icons.mjs
|
|
//
|
|
import { deflateSync } from "node:zlib";
|
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
import { dirname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const OUT = resolve(__dirname, "../public/icons");
|
|
|
|
// ---- PNG encoding -----------------------------------------------------------
|
|
|
|
const CRC_TABLE = (() => {
|
|
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_TABLE[(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 typeBuf = Buffer.from(type, "ascii");
|
|
const crcBuf = Buffer.alloc(4);
|
|
crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
|
|
return Buffer.concat([len, typeBuf, data, crcBuf]);
|
|
}
|
|
|
|
function encodePng(width, height, rgba) {
|
|
const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
const ihdr = Buffer.alloc(13);
|
|
ihdr.writeUInt32BE(width, 0);
|
|
ihdr.writeUInt32BE(height, 4);
|
|
ihdr[8] = 8; // bit depth
|
|
ihdr[9] = 6; // color type: RGBA
|
|
const stride = width * 4;
|
|
const raw = Buffer.alloc((stride + 1) * height);
|
|
for (let y = 0; y < height; y++) {
|
|
raw[y * (stride + 1)] = 0; // filter: none
|
|
rgba.copy(raw, y * (stride + 1) + 1, y * stride, y * stride + stride);
|
|
}
|
|
const idat = deflateSync(raw, { level: 9 });
|
|
return Buffer.concat([sig, chunk("IHDR", ihdr), chunk("IDAT", idat), chunk("IEND", Buffer.alloc(0))]);
|
|
}
|
|
|
|
// ---- Drawing ----------------------------------------------------------------
|
|
|
|
function makeCanvas(size) {
|
|
return { size, data: Buffer.alloc(size * size * 4, 0) };
|
|
}
|
|
|
|
function blend(canvas, x, y, [r, g, b], a) {
|
|
if (x < 0 || y < 0 || x >= canvas.size || y >= canvas.size || a <= 0) return;
|
|
const i = (y * canvas.size + x) * 4;
|
|
const d = canvas.data;
|
|
const sa = Math.min(1, a);
|
|
const da = d[i + 3] / 255;
|
|
const outA = sa + da * (1 - sa);
|
|
if (outA === 0) return;
|
|
d[i] = Math.round((r * sa + d[i] * da * (1 - sa)) / outA);
|
|
d[i + 1] = Math.round((g * sa + d[i + 1] * da * (1 - sa)) / outA);
|
|
d[i + 2] = Math.round((b * sa + d[i + 2] * da * (1 - sa)) / outA);
|
|
d[i + 3] = Math.round(outA * 255);
|
|
}
|
|
|
|
// Signed distance to a rounded rectangle, used for antialiased edges.
|
|
function roundedRectSdf(px, py, cx, cy, hw, hh, r) {
|
|
const qx = Math.abs(px - cx) - (hw - r);
|
|
const qy = Math.abs(py - cy) - (hh - r);
|
|
const ax = Math.max(qx, 0);
|
|
const ay = Math.max(qy, 0);
|
|
return Math.sqrt(ax * ax + ay * ay) + Math.min(Math.max(qx, qy), 0) - r;
|
|
}
|
|
|
|
function fillRoundedRect(canvas, cx, cy, hw, hh, r, color, alpha = 1) {
|
|
const { size } = canvas;
|
|
for (let y = 0; y < size; y++) {
|
|
for (let x = 0; x < size; x++) {
|
|
const d = roundedRectSdf(x + 0.5, y + 0.5, cx, cy, hw, hh, r);
|
|
const cov = Math.min(1, Math.max(0, 0.5 - d));
|
|
if (cov > 0) blend(canvas, x, y, color, cov * alpha);
|
|
}
|
|
}
|
|
}
|
|
|
|
function gradientAt(y, size, top, bottom) {
|
|
const t = Math.max(0, Math.min(1, y / (size - 1)));
|
|
return [
|
|
Math.round(top[0] + (bottom[0] - top[0]) * t),
|
|
Math.round(top[1] + (bottom[1] - top[1]) * t),
|
|
Math.round(top[2] + (bottom[2] - top[2]) * t),
|
|
];
|
|
}
|
|
|
|
function fillRoundedRectGradient(canvas, cx, cy, hw, hh, r, top, bottom) {
|
|
const { size } = canvas;
|
|
for (let y = 0; y < size; y++) {
|
|
const color = gradientAt(y + 0.5, size, top, bottom);
|
|
for (let x = 0; x < size; x++) {
|
|
const d = roundedRectSdf(x + 0.5, y + 0.5, cx, cy, hw, hh, r);
|
|
const cov = Math.min(1, Math.max(0, 0.5 - d));
|
|
if (cov > 0) blend(canvas, x, y, color, cov);
|
|
}
|
|
}
|
|
}
|
|
|
|
function fillCircle(canvas, cx, cy, r, color, alpha = 1) {
|
|
const { size } = canvas;
|
|
for (let y = 0; y < size; y++) {
|
|
for (let x = 0; x < size; x++) {
|
|
const d = Math.hypot(x + 0.5 - cx, y + 0.5 - cy) - r;
|
|
const cov = Math.min(1, Math.max(0, 0.5 - d));
|
|
if (cov > 0) blend(canvas, x, y, color, cov * alpha);
|
|
}
|
|
}
|
|
}
|
|
|
|
// derez.ai palette
|
|
const TOP = [0x00, 0x35, 0x45];
|
|
const BOTTOM = [0x00, 0x20, 0x2e];
|
|
const CYAN = [0x00, 0xf5, 0xff];
|
|
|
|
// Draws the derez.ai mark: a teal tile with the cyan terminal glyph.
|
|
function drawIcon(size, { maskable }) {
|
|
const canvas = makeCanvas(size);
|
|
const s = size;
|
|
const pad = maskable ? 0 : s * 0.05;
|
|
const radius = maskable ? 0 : s * 0.24;
|
|
|
|
fillRoundedRectGradient(canvas, s / 2, s / 2, s / 2 - pad, s / 2 - pad, radius, TOP, BOTTOM);
|
|
|
|
// The glyph is designed in a 34x34 box; scale it into the tile.
|
|
const k = (s * (maskable ? 0.7 : 0.84)) / 34;
|
|
const ox = (s - 34 * k) / 2;
|
|
const oy = (s - 34 * k) / 2;
|
|
const X = (v) => ox + v * k;
|
|
const Y = (v) => oy + v * k;
|
|
const stroke = 1.8 * k;
|
|
|
|
// Monitor outline: cyan rounded rect, then erase the interior with the tile gradient.
|
|
const mcx = X(17);
|
|
const mcy = Y(18.5);
|
|
const mhw = 11 * k;
|
|
const mhh = 7.5 * k;
|
|
const mr = 2.5 * k;
|
|
fillRoundedRect(canvas, mcx, mcy, mhw, mhh, mr, CYAN, 1);
|
|
fillRoundedRectGradient(
|
|
canvas,
|
|
mcx,
|
|
mcy,
|
|
mhw - stroke,
|
|
mhh - stroke,
|
|
Math.max(0, mr - stroke),
|
|
TOP,
|
|
BOTTOM,
|
|
);
|
|
|
|
// Antenna stalk + dot.
|
|
fillRoundedRect(canvas, X(17), Y(9), stroke / 2, 2 * k, stroke / 2, CYAN, 1);
|
|
fillCircle(canvas, X(17), Y(5.5), 1.5 * k, CYAN);
|
|
|
|
// Indicator dots.
|
|
fillCircle(canvas, X(10), Y(15.5), 1 * k, CYAN);
|
|
fillCircle(canvas, X(14), Y(15.5), 1 * k, CYAN);
|
|
|
|
// Bottom bar.
|
|
fillRoundedRect(canvas, X(17), Y(20), 7 * k, 0.75 * k, 0.75 * k, CYAN, 1);
|
|
|
|
return canvas;
|
|
}
|
|
|
|
function write(name, canvas) {
|
|
writeFileSync(resolve(OUT, name), encodePng(canvas.size, canvas.size, canvas.data));
|
|
console.log("wrote", name, `${canvas.size}x${canvas.size}`);
|
|
}
|
|
|
|
mkdirSync(OUT, { recursive: true });
|
|
write("icon-192.png", drawIcon(192, { maskable: false }));
|
|
write("icon-512.png", drawIcon(512, { maskable: false }));
|
|
write("maskable-512.png", drawIcon(512, { maskable: true }));
|
|
write("apple-touch-icon.png", drawIcon(180, { maskable: false }));
|
|
|
|
// Matching SVG favicon (the derez.ai mark, crisp at any size).
|
|
const favicon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 34 34">
|
|
<defs>
|
|
<linearGradient id="b" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="#003545"/>
|
|
<stop offset="100%" stop-color="#00202e"/>
|
|
</linearGradient>
|
|
</defs>
|
|
<rect x="0" y="0" width="34" height="34" rx="10" fill="url(#b)" stroke="#005570" stroke-width="1.2"/>
|
|
<rect x="6" y="11" width="22" height="15" rx="2.5" fill="none" stroke="#00f5ff" stroke-width="1.8"/>
|
|
<path d="M17 11V7" stroke="#00f5ff" stroke-width="1.8" stroke-linecap="round"/>
|
|
<circle cx="17" cy="5.5" r="1.5" fill="#00f5ff"/>
|
|
<circle cx="10" cy="15.5" r="1" fill="#00f5ff"/>
|
|
<circle cx="14" cy="15.5" r="1" fill="#00f5ff"/>
|
|
<path d="M10 20h14" stroke="#00f5ff" stroke-width="1.5" stroke-linecap="round"/>
|
|
</svg>
|
|
`;
|
|
writeFileSync(resolve(__dirname, "../public/favicon.svg"), favicon);
|
|
console.log("wrote favicon.svg");
|