Files
oliver c7b653ff6a Restructure: move Vite project into app/, deploy build output to repo root
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.
2026-11-04 14:05:46 -03:00

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");