feat(clock): replace icon with clock-on-cube design + OG share preview

The new PWA icon matches the supplied reference: dark background, a
clock face with an orange upper-right arc and hands at 12 and 4
o'clock, sitting on an isometric cube whose two visible faces each
have the MEGA 'M' carved into the background colour, with two small
orange base wedges under the cube.

When the /clock URL is shared (Slack, iMessage, etc.) the static
1200x630 opengraph-image.png is picked up automatically and renders
the icon alongside the 'MEGA Clock' wordmark and tagline, via
openGraph + twitter metadata added to the route.
This commit is contained in:
DEls 2026-09-13 21:55:40 +02:00
parent 978ddf03cc
commit 5f44d6a926
8 changed files with 243 additions and 86 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

View file

@ -162,95 +162,206 @@ function chatMaskableSvg(size, fontPx) {
</svg>`; </svg>`;
} }
// Non-maskable clock icon (192 and 512). Transparent background. // MEGA Clock icon — dark background with a clock face on top of an
// Layout: orange clock ring with tick marks, cube mark as the centre // isometric cube. Each cube face has a bold "M" carved out in the
// hub, "CLOCK" wordmark underneath — same brand DNA as the dashboard // background colour, and the cube has two small orange triangular
// and chat icons. // "shadow" wedges at the bottom. No wordmark; this icon stands on
function clockSvg(size, fontPx) { // its own the same way the dashboard and chat icons do.
function clockFaceAndHands() {
const cx = 256; const cx = 256;
const cy = 190; const cy = 175;
const r = 130; const r = 110;
const cubeSize = 90; // Clock hands: minute straight up, hour to 4 o'clock.
const cubeX = cx - cubeSize / 2; // 4 o'clock = 120° clockwise from 12 (measured around the dial).
const cubeY = cy - cubeSize / 2; const hourAngle = (120 * Math.PI) / 180;
const hourLen = r * 0.42;
const hourEndX = cx + hourLen * Math.sin(hourAngle);
const hourEndY = cy - hourLen * Math.cos(hourAngle);
const minuteEndY = cy - r * 0.82;
const wordY = 420; // Orange arc on the upper-right: 12 o'clock → 4 o'clock.
const arcEndAngle = (120 * Math.PI) / 180;
const arcEndX = cx + r * Math.sin(arcEndAngle);
const arcEndY = cy - r * Math.cos(arcEndAngle);
const ticks = []; return `
for (let i = 0; i < 12; i++) { <!-- Clock face (slightly lighter than the background) -->
const angle = (i * Math.PI * 2) / 12; <circle cx="${cx}" cy="${cy}" r="${r}" fill="#171c2a"/>
const inner = r - 14; <circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="#2a3144" stroke-width="2"/>
const outer = r - 2;
const x1 = cx + inner * Math.sin(angle);
const y1 = cy - inner * Math.cos(angle);
const x2 = cx + outer * Math.sin(angle);
const y2 = cy - outer * Math.cos(angle);
ticks.push(
`<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="${palette.black}" stroke-width="6" stroke-linecap="round"/>`,
);
}
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 512 512"> <!-- Orange arc (upper-right quadrant) -->
<defs> <path d="M ${cx} ${cy - r} A ${r} ${r} 0 0 1 ${arcEndX.toFixed(1)} ${arcEndY.toFixed(1)}"
<style type="text/css"> stroke="${palette.orange}" stroke-width="18" fill="none" stroke-linecap="round"/>
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 800;
src: url(data:font/woff;base64,${fontB64}) format('woff');
}
</style>
</defs>
<!-- Clock ring --> <!-- Minute hand (12 o'clock) -->
<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${palette.orange}" stroke-width="18"/> <line x1="${cx}" y1="${cy}" x2="${cx}" y2="${minuteEndY}"
${ticks.join("\n ")} stroke="${palette.orange}" stroke-width="12" stroke-linecap="round"/>
<!-- Cube mark (dashboard DNA) as the clock's centre hub --> <!-- Hour hand (4 o'clock) -->
<g transform="translate(${cubeX}, ${cubeY})"> <line x1="${cx}" y1="${cy}" x2="${hourEndX.toFixed(1)}" y2="${hourEndY.toFixed(1)}"
<rect x="0" y="0" width="${cubeSize}" height="${cubeSize}" rx="16" fill="${palette.black}"/> stroke="${palette.orange}" stroke-width="12" stroke-linecap="round"/>
<rect x="9" y="9" width="${cubeSize - 18}" height="${cubeSize - 18}" rx="10" fill="${palette.orange}"/>
<path d="M ${cubeSize / 2} 21 L ${cubeSize - 24} ${cubeSize / 2} L ${cubeSize / 2} ${cubeSize - 21} L 24 ${cubeSize / 2} Z" fill="${palette.black}"/> <!-- Centre hub -->
<circle cx="${cubeSize / 2}" cy="${cubeSize / 2}" r="8" fill="${palette.orange}"/> <circle cx="${cx}" cy="${cy}" r="11" fill="${palette.orange}"/>
`;
}
// A bold, geometric "M" centred at (cx, cy), width w, height h,
// thickness t. The result is a single filled path that can be tinted
// via fill — used cut into the cube faces (fill = background colour).
function mShapePath(cx, cy, w, h, t) {
const xL = cx - w / 2;
const xR = cx + w / 2;
const xLi = xL + t;
const xRi = xR - t;
const yT = cy - h / 2;
const yB = cy + h / 2;
// Outer peak drops about 40% down from the top; inner peak sits
// slightly above to keep the diagonal strokes consistent.
const peakOuter = cy - h / 2 + h * 0.42;
const peakInner = cy - h / 2 + h * 0.62;
return `
<path d="
M ${xL} ${yB}
L ${xL} ${yT}
L ${cx} ${peakOuter}
L ${xR} ${yT}
L ${xR} ${yB}
L ${xRi} ${yB}
L ${xRi} ${yT + 4}
L ${cx} ${peakInner}
L ${xLi} ${yT + 4}
L ${xLi} ${yB}
Z
"/>`;
}
// Isometric cube with the bold "M" cut into each of its two visible
// faces, plus the two orange base wedges below it. (x, y) is the
// top-centre of the cube; width of each face is 200px each side, so
// the cube spans from x-200 to x+200.
function clockCube(x, y) {
const topY = y;
const outerTopY = y + 30;
const outerBotY = y + 210;
const botY = y + 240;
const halfW = 200;
const xC = x;
const xL = x - halfW;
const xR = x + halfW;
// The cube faces are tilted parallelograms (top edges slope up to
// the centre by 30px across 200px → atan(30/200) ≈ 8.5°), so each
// M is rotated to sit on its face rather than floating upright.
const leftMCenter = { x: x - 100, y: y + 125 };
const rightMCenter = { x: x + 100, y: y + 125 };
return `
<!-- Left face (slightly lighter) -->
<polygon points="${xL},${outerTopY} ${xC},${topY} ${xC},${botY} ${xL},${outerBotY}" fill="#1f2434"/>
<!-- Right face (slightly darker so the centre edge reads as a fold) -->
<polygon points="${xC},${topY} ${xR},${outerTopY} ${xR},${outerBotY} ${xC},${botY}" fill="#171c2a"/>
<!-- M on left face — cut into background -->
<g transform="rotate(-8.5 ${leftMCenter.x} ${leftMCenter.y})">
${mShapePath(leftMCenter.x, leftMCenter.y, 70, 110, 16)}
</g> </g>
<!-- M on right face -->
<g transform="rotate(8.5 ${rightMCenter.x} ${rightMCenter.y})">
${mShapePath(rightMCenter.x, rightMCenter.y, 70, 110, 16)}
</g>
<!-- Orange base wedges (the "lit floor" effect at the cube's bottom corners) -->
<polygon points="${xL - 36},${outerBotY + 36} ${xL},${outerBotY} ${xC},${botY} ${xC - 36},${botY + 36}" fill="${palette.orange}"/>
<polygon points="${xC + 36},${botY + 36} ${xC},${botY} ${xR},${outerBotY} ${xR + 36},${outerBotY + 36}" fill="${palette.orange}"/>
`;
}
<!-- Wordmark --> function clockSvg(size, fontPx) {
<text x="256" y="${wordY}" font-family="Inter, system-ui, sans-serif" font-size="${fontPx}" font-weight="800" letter-spacing="6" text-anchor="middle" fill="${palette.black}">CLOCK</text> return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 512 512">
<!-- Dark background -->
<rect width="512" height="512" fill="${palette.black}"/>
${clockFaceAndHands()}
${clockCube(256, 310)}
</svg>`; </svg>`;
} }
// Maskable variant — orange fills the whole canvas. Content lives // Maskable variant — orange fills the whole canvas. Content lives
// inside the centre ~40% safe-zone (~205x205 inside a 512 canvas). // inside the centre ~40% safe-zone (~205x205 inside a 512 canvas).
// Uses the same clock + cube + M composition, recoloured as negative
// space (orange on black becomes black on orange).
function clockMaskableSvg(size, fontPx) { function clockMaskableSvg(size, fontPx) {
const scale = 205 / 512; const W = 512;
const offset = (512 - 205) / 2; const H = 512;
const offset = (W - 205) / 2;
const cx = W / 2;
const cy = offset + 60;
const r = 50;
const cx = offset + 102; // Hour hand to 4 o'clock, minute to 12.
const cy = offset + 75; const hourAngle = (120 * Math.PI) / 180;
const r = 55 * (scale / scale); // keep in the 205-wide safe zone const hourLen = r * 0.55;
const hourEndX = cx + hourLen * Math.sin(hourAngle);
const hourEndY = cy - hourLen * Math.cos(hourAngle);
const minuteEndY = cy - r * 0.82;
const cubeSize = 38; const arcEndAngle = (120 * Math.PI) / 180;
const cubeX = cx - cubeSize / 2; const arcEndX = cx + r * Math.sin(arcEndAngle);
const cubeY = cy - cubeSize / 2; const arcEndY = cy - r * Math.cos(arcEndAngle);
const wordY = offset + 175; // Tiny isometric cube below the clock, fully inside the safe zone.
const topY = offset + 130;
const outerTopY = topY + 8;
const outerBotY = topY + 50;
const botY = topY + 58;
const halfW = 60;
const xL = cx - halfW;
const xR = cx + halfW;
const ticks = []; return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${W} ${H}">
for (let i = 0; i < 12; i++) { <!-- Full-bleed orange background (OS-safe) -->
const angle = (i * Math.PI * 2) / 12; <rect width="${W}" height="${H}" fill="${palette.orange}"/>
const inner = r - 6;
const outer = r - 1;
const x1 = cx + inner * Math.sin(angle);
const y1 = cy - inner * Math.cos(angle);
const x2 = cx + outer * Math.sin(angle);
const y2 = cy - outer * Math.cos(angle);
ticks.push(
`<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="${palette.black}" stroke-width="2.5" stroke-linecap="round"/>`,
);
}
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 512 512"> <!-- Clock face (negative space: black on orange) -->
<circle cx="${cx}" cy="${cy}" r="${r}" fill="${palette.black}"/>
<!-- Orange arc on upper-right (knocked back into the black face) -->
<path d="M ${cx} ${cy - r} A ${r} ${r} 0 0 1 ${arcEndX.toFixed(1)} ${arcEndY.toFixed(1)}"
stroke="${palette.orange}" stroke-width="8" fill="none" stroke-linecap="round"/>
<!-- Minute hand -->
<line x1="${cx}" y1="${cy}" x2="${cx}" y2="${minuteEndY}"
stroke="${palette.orange}" stroke-width="6" stroke-linecap="round"/>
<!-- Hour hand -->
<line x1="${cx}" y1="${cy}" x2="${hourEndX.toFixed(1)}" y2="${hourEndY.toFixed(1)}"
stroke="${palette.orange}" stroke-width="6" stroke-linecap="round"/>
<!-- Centre hub -->
<circle cx="${cx}" cy="${cy}" r="5" fill="${palette.orange}"/>
<!-- Cube (negative-space: black on orange) -->
<polygon points="${xL},${outerTopY} ${cx},${topY} ${cx},${botY} ${xL},${outerBotY}" fill="${palette.black}"/>
<polygon points="${cx},${topY} ${xR},${outerTopY} ${xR},${outerBotY} ${cx},${botY}" fill="${palette.black}"/>
<!-- Two small M's in orange on the cube faces -->
<g fill="${palette.orange}">
${mShapePath(cx - 30, topY + 30, 24, 36, 6)}
${mShapePath(cx + 30, topY + 30, 24, 36, 6)}
</g>
</svg>`;
}
// 1200x630 Open Graph share image — left: the dark clock + cube
// icon at a generous size; right: "MEGA Clock" wordmark and a
// short tagline. Generated as a static PNG so Slack/iMessage/etc.
// pick it up automatically from the route segment.
function clockOgSvg() {
const W = 1200;
const H = 630;
// Subtle dot grid + faint orange glow upper-right, same family as
// the dashboard OG image so the share previews feel related.
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
<defs> <defs>
<style type="text/css"> <style type="text/css">
@font-face { @font-face {
@ -260,25 +371,43 @@ function clockMaskableSvg(size, fontPx) {
src: url(data:font/woff;base64,${fontB64}) format('woff'); src: url(data:font/woff;base64,${fontB64}) format('woff');
} }
</style> </style>
<radialGradient id="glow" cx="0.88" cy="0.12" r="0.55">
<stop offset="0%" stop-color="${palette.orange}" stop-opacity="0.35"/>
<stop offset="60%" stop-color="${palette.orange}" stop-opacity="0.05"/>
<stop offset="100%" stop-color="${palette.orange}" stop-opacity="0"/>
</radialGradient>
<pattern id="grid" width="48" height="48" patternUnits="userSpaceOnUse">
<path d="M 48 0 L 0 0 0 48" fill="none" stroke="rgba(255,255,255,0.04)" stroke-width="1"/>
</pattern>
</defs> </defs>
<!-- Full-bleed orange background (OS-safe) --> <!-- Background -->
<rect width="512" height="512" fill="${palette.orange}"/> <rect width="${W}" height="${H}" fill="${palette.black}"/>
<rect width="${W}" height="${H}" fill="url(#grid)"/>
<rect width="${W}" height="${H}" fill="url(#glow)"/>
<!-- Clock ring (negative-space: black on orange) --> <!-- Icon block on the left (smaller, padded 70px in) -->
<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${palette.black}" stroke-width="7"/> <svg x="70" y="115" width="400" height="400" viewBox="0 0 512 512">
${ticks.join("\n ")} <rect width="512" height="512" fill="${palette.black}"/>
${clockFaceAndHands()}
${clockCube(256, 310)}
</svg>
<!-- Cube mark --> <!-- Brand line -->
<g transform="translate(${cubeX}, ${cubeY})"> <text x="520" y="220" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="700" letter-spacing="6" fill="${palette.orange}">MEGA INFRASTRUCTURE</text>
<rect x="0" y="0" width="${cubeSize}" height="${cubeSize}" rx="7" fill="${palette.black}"/>
<rect x="4" y="4" width="${cubeSize - 8}" height="${cubeSize - 8}" rx="4" fill="${palette.orange}"/> <!-- Big title -->
<path d="M ${cubeSize / 2} 10 L ${cubeSize - 11} ${cubeSize / 2} L ${cubeSize / 2} ${cubeSize - 10} L 11 ${cubeSize / 2} Z" fill="${palette.black}"/> <text x="520" y="340" font-family="Inter, system-ui, sans-serif" font-size="92" font-weight="800" letter-spacing="-2" fill="#ffffff">MEGA Clock</text>
<circle cx="${cubeSize / 2}" cy="${cubeSize / 2}" r="3.5" fill="${palette.orange}"/>
<!-- Tagline -->
<text x="520" y="410" font-family="Inter, system-ui, sans-serif" font-size="26" font-weight="500" fill="rgba(255,255,255,0.75)">Scan a worker&apos;s badge to clock in or out.</text>
<!-- Bottom accent bars (same family as the dashboard OG image) -->
<g transform="translate(520, 540)">
<rect width="180" height="6" rx="3" fill="${palette.orange}"/>
<rect x="200" width="180" height="6" rx="3" fill="#a1ae3b"/>
<rect x="400" width="180" height="6" rx="3" fill="#4f9c5b"/>
</g> </g>
<!-- Wordmark (negative-space: black on orange) -->
<text x="256" y="${wordY}" font-family="Inter, system-ui, sans-serif" font-size="${fontPx}" font-weight="800" letter-spacing="3" text-anchor="middle" fill="${palette.black}">CLOCK</text>
</svg>`; </svg>`;
} }
@ -328,13 +457,28 @@ await renderAppleIcon(
); );
// MEGA Clock icon set (non-maskable 192/512, maskable 512) // MEGA Clock icon set (non-maskable 192/512, maskable 512)
await renderSvg(clockSvg(512, 80), path.join(ROOT, "public/icons/clock-icon-512.png"), 512); await renderSvg(clockSvg(512, 0), path.join(ROOT, "public/icons/clock-icon-512.png"), 512);
await renderSvg(clockSvg(192, 30), path.join(ROOT, "public/icons/clock-icon-192.png"), 192); await renderSvg(clockSvg(192, 0), path.join(ROOT, "public/icons/clock-icon-192.png"), 192);
await renderSvg(clockMaskableSvg(512, 38), path.join(ROOT, "public/icons/clock-maskable-512.png"), 512); await renderSvg(clockMaskableSvg(512, 0), path.join(ROOT, "public/icons/clock-maskable-512.png"), 512);
await renderAppleIcon( await renderAppleIcon(
path.join(ROOT, "public/icons/clock-icon-512.png"), path.join(ROOT, "public/icons/clock-icon-512.png"),
path.join(ROOT, "src/app/clock/apple-icon.png"), path.join(ROOT, "src/app/clock/apple-icon.png"),
); );
// Open Graph share image for /clock. Next.js auto-detects
// `opengraph-image.png` placed in a route segment and serves it
// with the matching <meta property="og:image"> tag, so Slack /
// iMessage / etc. preview the icon + title when the link is shared.
{
const out = path.join(ROOT, "src/app/clock/opengraph-image.png");
const buf = Buffer.from(clockOgSvg());
await sharp(buf, { density: 144 })
.resize({ width: 1200, height: 630 })
.png({ compressionLevel: 9 })
.toFile(out);
const stat = await fs.stat(out);
console.log(`Wrote ${out} (${(stat.size / 1024).toFixed(1)} KB, 1200x630)`);
}
console.log("All icons rendered."); console.log("All icons rendered.");

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

View file

@ -0,0 +1 @@
MEGA Clock — the standalone QR timekeeping PWA. The icon shows a clock face with an orange upper-right arc, hands at 12 and 4 o'clock, sitting on top of an isometric cube with the MEGA "M" carved into both faces.

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

View file

@ -3,12 +3,24 @@ import { ClockView } from "./clock-view";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Clock · MEGA INFRASTRUCTURE", title: "Clock · MEGA INFRASTRUCTURE",
description:
"Scan a worker's badge to clock in or out. MEGA Clock is the standalone QR timekeeping PWA for site phones.",
manifest: "/clock.webmanifest", manifest: "/clock.webmanifest",
appleWebApp: { appleWebApp: {
capable: true, capable: true,
title: "Clock", title: "Clock",
statusBarStyle: "default", statusBarStyle: "default",
}, },
openGraph: {
title: "MEGA Clock",
description: "Scan a worker's badge to clock in or out.",
type: "website",
},
twitter: {
card: "summary_large_image",
title: "MEGA Clock",
description: "Scan a worker's badge to clock in or out.",
},
}; };
export default function ClockPage() { export default function ClockPage() {