diff --git a/README.md b/README.md
index 98ab685..b7c71d9 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,6 @@
-

-
-# Motionity
+

**Web-based motion graphics editor** — keyframing, masking, filters, text animations.
A free alternative to After Effects and Canva, running entirely in the browser.
diff --git a/package.json b/package.json
index 6dbacc1..54f1f3a 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"scripts": {
"vendor": "node scripts/vendor.mjs",
"icons": "node scripts/make-icon.cjs",
+ "favicon": "node scripts/make-favicon.cjs",
"start": "node scripts/server.cjs",
"dev": "electron .",
"dist:win": "npm run vendor && npm run icons && electron-builder --win",
diff --git a/scripts/make-favicon.cjs b/scripts/make-favicon.cjs
new file mode 100644
index 0000000..d358b0d
--- /dev/null
+++ b/scripts/make-favicon.cjs
@@ -0,0 +1,109 @@
+#!/usr/bin/env node
+// Renders src/favicon.ico (32x32) from the same geometry as
+// src/assets/logo.svg: three coloured rounded bars on the app's dark background.
+//
+// Hand-rolled so packaging needs no image toolchain (no ImageMagick, no sharp).
+
+const { writeFileSync } = require('node:fs');
+const { resolve } = require('node:path');
+
+const SIZE = 32;
+const SS = 8; // supersampling factor, for antialiased edges at small size
+const BG = [0x14, 0x16, 0x29];
+const BG_RADIUS = 6; // squircle-ish corner on the icon plate
+
+// viewBox="0 0 157 183" in src/assets/logo.svg
+const VIEW = { w: 157, h: 183 };
+const BARS = [
+ { x: 79, y: 0, w: 79, h: 47, r: 23.5, fg: [0x75, 0x4b, 0xff] },
+ { x: 0, y: 68, w: 157, h: 47, r: 23.5, fg: [0x60, 0x2f, 0xff] },
+ { x: 0, y: 136, w: 79, h: 47, r: 23.5, fg: [0x06, 0x93, 0xc0] },
+];
+
+function insideRoundedRect(px, py, { x, y, w, h, r }) {
+ const dx = Math.max(x + r - px, 0, px - (x + w - r));
+ const dy = Math.max(y + r - py, 0, py - (y + h - r));
+ return dx * dx + dy * dy <= r * r;
+}
+
+// The logo occupies 62% of the plate, centred.
+const scale = (SIZE * 0.62) / VIEW.h;
+const offsetX = (SIZE - VIEW.w * scale) / 2;
+const offsetY = (SIZE - VIEW.h * scale) / 2;
+
+const plate = { x: 0, y: 0, w: SIZE, h: SIZE, r: BG_RADIUS };
+
+// BGRA pixels, row-major, top-to-bottom.
+const pixels = Buffer.alloc(SIZE * SIZE * 4);
+for (let py = 0; py < SIZE; py++) {
+ for (let px = 0; px < SIZE; px++) {
+ let plateHits = 0;
+ const colorSum = [0, 0, 0];
+ for (let sy = 0; sy < SS; sy++) {
+ for (let sx = 0; sx < SS; sx++) {
+ const fx = px + (sx + 0.5) / SS;
+ const fy = py + (sy + 0.5) / SS;
+ if (!insideRoundedRect(fx, fy, plate)) continue;
+ plateHits++;
+ const lx = (fx - offsetX) / scale;
+ const ly = (fy - offsetY) / scale;
+ const bar = BARS.find((b) => insideRoundedRect(lx, ly, b));
+ const pixel = bar ? bar.fg : BG;
+ for (let c = 0; c < 3; c++) colorSum[c] += pixel[c];
+ }
+ }
+ const total = SS * SS;
+ const alpha = Math.round((plateHits / total) * 255);
+ const o = (py * SIZE + px) * 4;
+ const r = plateHits ? Math.round(colorSum[0] / plateHits) : 0;
+ const g = plateHits ? Math.round(colorSum[1] / plateHits) : 0;
+ const b = plateHits ? Math.round(colorSum[2] / plateHits) : 0;
+ pixels[o] = b;
+ pixels[o + 1] = g;
+ pixels[o + 2] = r;
+ pixels[o + 3] = alpha;
+ }
+}
+
+// Pack into a single-image .ico: ICONDIR + ICONDIRENTRY + BITMAPINFOHEADER
+// + 32bpp XOR data (bottom-up) + 1bpp AND mask (bottom-up, all-zero since
+// the XOR data already carries a full alpha channel).
+const xor = Buffer.alloc(SIZE * SIZE * 4);
+for (let py = 0; py < SIZE; py++) {
+ const srcRow = py * SIZE * 4;
+ const dstRow = (SIZE - 1 - py) * SIZE * 4; // bottom-up
+ pixels.copy(xor, dstRow, srcRow, srcRow + SIZE * 4);
+}
+const andMaskRowBytes = Math.ceil(SIZE / 32) * 4; // 1bpp, padded to 4 bytes
+const and = Buffer.alloc(andMaskRowBytes * SIZE, 0);
+
+const dib = Buffer.alloc(40);
+dib.writeUInt32LE(40, 0); // biSize
+dib.writeInt32LE(SIZE, 4); // biWidth
+dib.writeInt32LE(SIZE * 2, 8); // biHeight (XOR + AND)
+dib.writeUInt16LE(1, 12); // biPlanes
+dib.writeUInt16LE(32, 14); // biBitCount
+dib.writeUInt32LE(0, 16); // biCompression: BI_RGB
+
+const imageData = Buffer.concat([dib, xor, and]);
+
+const iconDir = Buffer.alloc(6);
+iconDir.writeUInt16LE(0, 0); // reserved
+iconDir.writeUInt16LE(1, 2); // type: icon
+iconDir.writeUInt16LE(1, 4); // image count
+
+const entry = Buffer.alloc(16);
+entry[0] = SIZE; // width
+entry[1] = SIZE; // height
+entry[2] = 0; // color count
+entry[3] = 0; // reserved
+entry.writeUInt16LE(1, 4); // planes
+entry.writeUInt16LE(32, 6); // bit count
+entry.writeUInt32LE(imageData.length, 8); // bytes in resource
+entry.writeUInt32LE(6 + 16, 12); // image offset
+
+const ico = Buffer.concat([iconDir, entry, imageData]);
+
+const dest = resolve(__dirname, '..', 'src', 'favicon.ico');
+writeFileSync(dest, ico);
+console.log(`Wrote ${dest} (${SIZE}x${SIZE}, ${Math.round(ico.length / 1024)} KB)`);
diff --git a/scripts/make-icon.cjs b/scripts/make-icon.cjs
index ae2f174..baa90ee 100644
--- a/scripts/make-icon.cjs
+++ b/scripts/make-icon.cjs
@@ -1,6 +1,6 @@
#!/usr/bin/env node
// Renders build/icon.png (512x512) from the same geometry as
-// src/assets/logo.svg: three white rounded bars on the app's dark background.
+// src/assets/logo.svg: three coloured rounded bars on the app's dark background.
//
// Hand-rolled so packaging needs no image toolchain (no ImageMagick, no sharp).
// electron-builder derives the Windows .ico and the Linux icon set from it.
@@ -12,15 +12,14 @@ const { join, resolve } = require('node:path');
const SIZE = 512;
const SS = 4; // supersampling factor, for antialiased edges
const BG = [0x14, 0x16, 0x29];
-const FG = [0xff, 0xff, 0xff];
const BG_RADIUS = 96; // squircle-ish corner on the icon plate
-// viewBox="0 0 24 19" in src/assets/logo.svg
-const VIEW = { w: 24, h: 19 };
+// viewBox="0 0 157 183" in src/assets/logo.svg
+const VIEW = { w: 157, h: 183 };
const BARS = [
- { x: 17.3193, y: 8.36011, w: 6.08, h: 10.64, r: 3.04 },
- { x: 8.95996, y: 0, w: 6.08, h: 19, r: 3.04 },
- { x: 0.599609, y: 0, w: 6.08, h: 19, r: 3.04 },
+ { x: 79, y: 0, w: 79, h: 47, r: 23.5, fg: [0x75, 0x4b, 0xff] },
+ { x: 0, y: 68, w: 157, h: 47, r: 23.5, fg: [0x60, 0x2f, 0xff] },
+ { x: 0, y: 136, w: 79, h: 47, r: 23.5, fg: [0x06, 0x93, 0xc0] },
];
function insideRoundedRect(px, py, { x, y, w, h, r }) {
@@ -43,7 +42,7 @@ for (let py = 0; py < SIZE; py++) {
raw[rowStart] = 0;
for (let px = 0; px < SIZE; px++) {
let plateHits = 0;
- let barHits = 0;
+ const colorSum = [0, 0, 0];
for (let sy = 0; sy < SS; sy++) {
for (let sx = 0; sx < SS; sx++) {
const fx = px + (sx + 0.5) / SS;
@@ -52,16 +51,16 @@ for (let py = 0; py < SIZE; py++) {
plateHits++;
const lx = (fx - offsetX) / scale;
const ly = (fy - offsetY) / scale;
- if (BARS.some((bar) => insideRoundedRect(lx, ly, bar))) barHits++;
+ const bar = BARS.find((b) => insideRoundedRect(lx, ly, b));
+ const pixel = bar ? bar.fg : BG;
+ for (let c = 0; c < 3; c++) colorSum[c] += pixel[c];
}
}
const total = SS * SS;
const alpha = Math.round((plateHits / total) * 255);
- // Blend bar coverage over the plate colour; alpha carries the plate edge.
- const mix = plateHits ? barHits / plateHits : 0;
const o = rowStart + 1 + px * 4;
for (let c = 0; c < 3; c++) {
- raw[o + c] = Math.round(BG[c] + (FG[c] - BG[c]) * mix);
+ raw[o + c] = plateHits ? Math.round(colorSum[c] / plateHits) : 0;
}
raw[o + 3] = alpha;
}
diff --git a/src/assets/logo-full.svg b/src/assets/logo-full.svg
new file mode 100644
index 0000000..3b3956c
--- /dev/null
+++ b/src/assets/logo-full.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/logo.svg b/src/assets/logo.svg
index c22b359..d324643 100644
--- a/src/assets/logo.svg
+++ b/src/assets/logo.svg
@@ -1,5 +1 @@
-
+
\ No newline at end of file
diff --git a/src/favicon.ico b/src/favicon.ico
index 55c1f42..1592f5a 100644
Binary files a/src/favicon.ico and b/src/favicon.ico differ