diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45fc826..394f0b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,9 @@ jobs: - name: AI engine tests (115 checks) run: npm run test:ai + - name: Branding regression gate (complete official artwork) + run: npm run test:branding + - name: Production build (includes type checking) env: NEXT_PUBLIC_BASE_PATH: /niramay diff --git a/components/brand/NiramayLogo.tsx b/components/brand/NiramayLogo.tsx index 5d7984f..a8a5357 100644 --- a/components/brand/NiramayLogo.tsx +++ b/components/brand/NiramayLogo.tsx @@ -6,15 +6,15 @@ const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; interface NiramayLogoProps { size?: number; /** - * emblem — square mark-only crop of the official artwork (mortar & pestle - * with tulsi, turmeric and black pepper), for compact/icon-class - * contexts (header, buttons, favicon-class surfaces, print/share - * card headers). Never includes the wordmark or tagline — those - * are illegible at these sizes. - * full — the complete official lockup image (mark + "NIRAMAY" wordmark - * + "DISCOVER • HEAL • LIVE BETTER" tagline, baked into a single - * artwork file). Use only where there is genuine vertical room - * for it to render legibly (e.g. the footer brand column). + * BOTH variants now carry the COMPLETE official artwork (symbol + "NIRAMAY" + * wordmark + "DISCOVER • HEAL • LIVE BETTER" tagline), per the Phase 3 + * mandate: no symbol-only assets, no selective extraction of the mark. + * emblem — square 512px complete-artwork image for compact contexts + * (header, buttons, favicon-class surfaces). At small sizes the + * wordmark/tagline are present but not legible — an accepted, + * documented limitation of the complete-lockup mandate. + * full — the same complete lockup image, rendered larger (e.g. the + * footer brand column) where the full composition stays legible. */ variant?: "emblem" | "full"; /** Extra classes for the emblem image (ring/medallion tweaks). Ignored for variant="full". */ diff --git a/package-lock.json b/package-lock.json index 7c6338e..3b7574d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "niramay", - "version": "2.1.0", + "version": "2.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "niramay", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@hookform/resolvers": "^5.2.1", "autoprefixer": "^10.4.21", @@ -1885,17 +1885,6 @@ "node": ">=12.4.0" } }, - "node_modules/@opentelemetry/api": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", - "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2926,43 +2915,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -4522,24 +4474,6 @@ "pako": "^2.1.0" } }, - "node_modules/fast-uri": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause", - "optional": true, - "peer": true - }, "node_modules/fastq": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", @@ -5613,14 +5547,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -6795,17 +6721,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resolve": { "version": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", diff --git a/package.json b/package.json index 8de1fe4..d1b35d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "niramay", - "version": "2.1.0", + "version": "2.1.1", "private": true, "scripts": { "dev": "next dev", @@ -8,7 +8,8 @@ "start": "npx serve@latest out", "lint": "eslint .", "clean": "next clean", - "test:ai": "tsx scripts/test-ai-engine.ts" + "test:ai": "tsx scripts/test-ai-engine.ts", + "test:branding": "tsx scripts/verify-branding.ts" }, "dependencies": { "@hookform/resolvers": "^5.2.1", diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index 0f90d85..907af1b 100644 Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ diff --git a/public/brand/niramay-logo-full.png b/public/brand/niramay-logo-full.png index 1dfd406..5db2edc 100644 Binary files a/public/brand/niramay-logo-full.png and b/public/brand/niramay-logo-full.png differ diff --git a/public/brand/niramay-mark.png b/public/brand/niramay-mark.png index 44d7f52..5db2edc 100644 Binary files a/public/brand/niramay-mark.png and b/public/brand/niramay-mark.png differ diff --git a/public/favicon.ico b/public/favicon.ico index c563e26..b828396 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/icon-192.png b/public/icon-192.png index 4e379c7..51c25e9 100644 Binary files a/public/icon-192.png and b/public/icon-192.png differ diff --git a/public/icon-512-maskable.png b/public/icon-512-maskable.png index b9f1b9d..eba0c82 100644 Binary files a/public/icon-512-maskable.png and b/public/icon-512-maskable.png differ diff --git a/public/icon-512.png b/public/icon-512.png index 44d7f52..5db2edc 100644 Binary files a/public/icon-512.png and b/public/icon-512.png differ diff --git a/public/niramay-logo-official.png b/public/niramay-logo-official.png new file mode 100644 index 0000000..ad70665 Binary files /dev/null and b/public/niramay-logo-official.png differ diff --git a/scripts/verify-branding.ts b/scripts/verify-branding.ts new file mode 100644 index 0000000..6253e25 --- /dev/null +++ b/scripts/verify-branding.ts @@ -0,0 +1,193 @@ +/** + * OFFICIAL NIRAMAY BRANDING REGRESSION SUITE (Phase 3, 2026-10-16) + * ------------------------------------------------------------------------- + * Single source of truth: public/niramay-logo-official.png — the supplied + * official artwork (1254x1254, sha256 pinned below). Every icon asset in + * this repo must be a deterministic whole-artwork derivation of it: + * the COMPLETE composition (symbol + "NIRAMAY" wordmark + + * "DISCOVER • HEAL • LIVE BETTER" tagline) with NO cropping, NO recoloring, + * NO selective symbol extraction, and NO hand-drawn replacement. + * + * This suite fails the build when: + * - the master artwork file is not the supplied official image; + * - any PWA/apple/favicon asset no longer matches its pinned fingerprint + * (old mortar-and-pestle artwork detection); + * - the maskable-512 content is not the complete composition or breaks the + * 66/108 safe circle (mask clipping risk); + * - the maskable canvas background is not the artwork's own cream; + * - the APK build inputs on the platform side would still bake the old icon + * (this repo pins only its own assets; the platform launcher input is + * gated by the platform repo's own suite). + * + * Geometry is measured from decoded pixels (dependency-free PNG decode), + * not assumed from file names. + */ +import { ok, strictEqual } from 'node:assert'; +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { inflateSync } from 'node:zlib'; + +const sha256 = (p: string) => createHash('sha256').update(readFileSync(p)).digest('hex'); + +/* ------------------------------------------------------------------ */ +/* Minimal PNG decoder (RGB, no interlace) — enough for geometry checks */ +/* ------------------------------------------------------------------ */ +function decodePng(path: string): { w: number; h: number; rgb: Uint8Array } { + const buf = readFileSync(path); + const w = buf.readUInt32BE(16); + const h = buf.readUInt32BE(20); + const bitDepth = buf[24]; + const colorType = buf[25]; + if (bitDepth !== 8 || (colorType !== 0 && colorType !== 2)) { + throw new Error(`unsupported PNG (depth=${bitDepth} color=${colorType}): ${path}`); + } + const stride = colorType === 2 ? w * 3 : w; + let idat: Buffer[] = []; + let pos = 8; + while (pos < buf.length) { + const len = buf.readUInt32BE(pos); + const type = buf.toString('ascii', pos + 4, pos + 8); + if (type === 'IDAT') idat.push(buf.subarray(pos + 8, pos + 8 + len)); + pos += 12 + len; + if (type === 'IEND') break; + } + const raw = inflateSync(Buffer.concat(idat)); + const rgb = new Uint8Array(w * h * 3); + let prev = new Uint8Array(stride); + for (let y = 0; y < h; y++) { + const filter = raw[y * (stride + 1)]; + const row = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)); + const cur = new Uint8Array(stride); + for (let i = 0; i < stride; i++) { + const a = i >= (colorType === 2 ? 3 : 1) ? cur[i - (colorType === 2 ? 3 : 1)] : 0; + const b = prev[i]; + const c = i >= (colorType === 2 ? 3 : 1) ? prev[i - (colorType === 2 ? 3 : 1)] : 0; + let v = row[i]; + if (filter === 1) v = (v + a) & 0xff; + else if (filter === 2) v = (v + b) & 0xff; + else if (filter === 3) v = (v + ((a + b) >> 1)) & 0xff; + else if (filter === 4) { + const p = a + b - c; + const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); + v = (v + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c)) & 0xff; + } + cur[i] = v; + } + if (colorType === 2) rgb.set(cur, y * w * 3); + else for (let x = 0; x < w; x++) { const g = cur[x]; rgb[y * w * 3 + x * 3] = g; rgb[y * w * 3 + x * 3 + 1] = g; rgb[y * w * 3 + x * 3 + 2] = g; } + prev = cur; + } + return { w, h, rgb }; +} + +function contentBbox(path: string, bg: [number, number, number]) { + const { w, h, rgb } = decodePng(path); + let x0 = w, y0 = h, x1 = -1, y1 = -1; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const i = (y * w + x) * 3; + const d = Math.abs(rgb[i] - bg[0]) + Math.abs(rgb[i + 1] - bg[1]) + Math.abs(rgb[i + 2] - bg[2]); + if (d > 18) { + if (x < x0) x0 = x; + if (x > x1) x1 = x; + if (y < y0) y0 = y; + if (y > y1) y1 = y; + } + } + } + if (x1 < 0) throw new Error(`no content detected: ${path}`); + return { w, h, x0, y0, x1, y1 }; +} + +let n = 0; +const pass = (label: string, cond: boolean, detail = '') => { + ok(cond, `FAILED: ${label}${detail ? ` (${detail})` : ''}`); + n += 1; + console.log(` PASS ${label}`); +}; + +/* ------------------------------------------------------------------ */ +/* 1. Master artwork is the supplied single source of truth */ +/* ------------------------------------------------------------------ */ +console.log('[branding] master artwork'); +const MASTER_SHA = '1b933703d7bf38be5571744de7478cce1b80c293223b1c31ed93984149b67ff4'; +pass('niramay-logo-official.png is the supplied official artwork', + sha256('public/niramay-logo-official.png') === MASTER_SHA, + sha256('public/niramay-logo-official.png')); + +/* ------------------------------------------------------------------ */ +/* 2. Pinned fingerprints — every asset is a whole-artwork derivative */ +/* ------------------------------------------------------------------ */ +console.log('[branding] asset fingerprints (old-artwork detection)'); +const PINS: Record = { + 'public/brand/niramay-logo-full.png': '3e38d2f8e6c3c169902d3db6e057b0d100ae72d50982d9820ae320b32d2828d0', + 'public/brand/niramay-mark.png': '3e38d2f8e6c3c169902d3db6e057b0d100ae72d50982d9820ae320b32d2828d0', + 'public/icon-512.png': '3e38d2f8e6c3c169902d3db6e057b0d100ae72d50982d9820ae320b32d2828d0', + 'public/icon-192.png': '32353997b204f5ee31fb52dd46a6f058d33ea36b5707a377de5dfaf0eaf039a9', + 'public/apple-touch-icon.png': '87245deadee97b897536179b15370c6657b767c7d28e7e2cd44221c14c4504a5', + 'public/icon-512-maskable.png': '444f6912eeecc3484d73c47030ac6d022b1c632a03b56157fa83877b1c36600c', +}; +for (const [p, expected] of Object.entries(PINS)) { + pass(`${p} matches the complete-artwork fingerprint`, sha256(p) === expected, sha256(p)); +} +{ + const ico = readFileSync('public/favicon.ico'); + const type = ico.readUInt16LE(2); + const count = ico.readUInt16LE(4); + const sizes: number[] = []; + for (let i = 0; i < count; i++) { + const e = 6 + i * 16; + sizes.push(ico[e] === 0 ? 256 : ico[e]); + } + pass('favicon.ico is a valid multi-resolution icon of the complete artwork', + type === 1 && count === 3 && sizes.includes(16) && sizes.includes(32) && sizes.includes(48), + `type=${type} sizes=${sizes.join('/')}`); + pass('favicon.ico fingerprint pinned (old-artwork detection)', + sha256('public/favicon.ico') === '85284c3377b3e93fcd7f95aa6216e9ebb0fc3dd3b4594cacfd925cbaaf9868b3'); +} + +/* ------------------------------------------------------------------ */ +/* 3. Maskable-512 geometry: complete composition inside the safe zone */ +/* ------------------------------------------------------------------ */ +console.log('[branding] maskable-512 safe-area geometry'); +const BG: [number, number, number] = [253, 253, 249]; // artwork cream, measured from master corners +{ + const { w, h, x0, y0, x1, y1 } = contentBbox('public/icon-512-maskable.png', BG); + const cw = x1 - x0 + 1, ch = y1 - y0 + 1; + const ratio = ch / cw; + // complete composition content is 845/870 = 0.971; symbol-only would be ~0.6 + pass('maskable content is the COMPLETE composition (wordmark+tagline present)', + ratio > 0.9 && ratio < 1.05, `aspect ${ratio.toFixed(3)}`); + const far = Math.hypot(x1 - w / 2, y1 - h / 2); + const safe = (w * 66) / 108 / 2; + pass('maskable content corners inside the 66/108 safe circle', + far <= safe + 1, `${far.toFixed(1)} <= ${safe.toFixed(1)} (+1px raster tolerance)`); + const cxOff = Math.abs((x0 + x1) / 2 - w / 2), cyOff = Math.abs((y0 + y1) / 2 - h / 2); + pass('maskable content centered', cxOff <= 2 && cyOff <= 2, `dx ${cxOff.toFixed(1)} dy ${cyOff.toFixed(1)}`); +} + +/* ------------------------------------------------------------------ */ +/* 4. Component wiring: both logo variants use the new complete assets */ +/* ------------------------------------------------------------------ */ +console.log('[branding] component + manifest wiring'); +const logoSrc = readFileSync('components/brand/NiramayLogo.tsx', 'utf8'); +pass('NiramayLogo emblem variant serves the complete artwork (niramay-mark.png)', + /niramay-mark\.png/.test(logoSrc) && !/mark-only/i.test(logoSrc)); +pass('NiramayLogo full variant serves the complete artwork (niramay-logo-full.png)', + /niramay-logo-full\.png/.test(logoSrc)); +const manifest = readFileSync('app/manifest.ts', 'utf8'); +pass('PWA manifest references icon-512 + icon-512-maskable', /icon-512\.png/.test(manifest) && /icon-512-maskable\.png/.test(manifest)); +const layout = readFileSync('app/layout.tsx', 'utf8'); +pass('layout metadata references favicon.ico + apple-touch-icon', /favicon\.ico/.test(layout) && /apple-touch-icon\.png/.test(layout)); + +/* ------------------------------------------------------------------ */ +/* 5. Version convention */ +/* ------------------------------------------------------------------ */ +console.log('[branding] version'); +{ + const pkg = JSON.parse(readFileSync('package.json', 'utf8')); + pass('package.json version is the Phase 3 bump (>= 2.1.1)', + /^\d+\.\d+\.\d+$/.test(pkg.version) && pkg.version >= '2.1.1', pkg.version); +} + +console.log(`[branding] ${n} checks passed`);