diff --git a/public/sw.js b/public/sw.js index ecb05fb8..2825fdb4 100644 --- a/public/sw.js +++ b/public/sw.js @@ -14,7 +14,7 @@ // Update this version string manually to keep the app + cache versions in sync. // The value is forwarded to the UI via the service worker "SW_ACTIVATED" message. -const APP_VERSION = "42.3.0"; // update on release +const APP_VERSION = "42.4.0"; // update on release const VERSION = new URL(self.location.href).searchParams.get("v") || APP_VERSION; // derived from build const PREFIX = "PHINETWORK"; diff --git a/src/og/buildVerifiedCardSvg.ts b/src/og/buildVerifiedCardSvg.ts index 91cdfdd9..426d1176 100644 --- a/src/og/buildVerifiedCardSvg.ts +++ b/src/og/buildVerifiedCardSvg.ts @@ -6,9 +6,17 @@ import { buildProofOfBreathSeal } from "./proofOfBreathSeal"; export const VERIFIED_CARD_W = 1200; export const VERIFIED_CARD_H = 630; + +const NOTE_TITLE_TEXT = "KAIROS KURRENCY"; +const NOTE_SUBTITLE_TEXT = "ISSUED UNDER YAHUAH’S LAW OF ETERNAL LIGHT — Φ • KAI-TURAH"; + const PHI = (1 + Math.sqrt(5)) / 2; + const phiLogoDataUri = svgToDataUri(phiSvg); +type UnknownRecord = Record; +type CardKind = "proof" | "money"; + function hashStringToInt(value: string): number { let hash = 0; for (let i = 0; i < value.length; i += 1) { @@ -32,46 +40,30 @@ function shortPhiKey(phiKey: string): string { return `${trimmed.slice(0, 6)}…${trimmed.slice(-4)}`; } -function badgeMark(ok: boolean): string { - if (ok) { - return "M18 32 L28 42 L46 18"; - } - return "M20 20 L44 44 M44 20 L20 44"; +function shortHash(value: string | undefined, head = 10, tail = 8): string { + if (!value) return "—"; + if (value.length <= head + tail + 2) return value; + return `${value.slice(0, head)}…${value.slice(-tail)}`; } -type TextMetricsLite = { width: number; ascent: number; descent: number }; - -function measureTextMetrics( - text: string, - font: string, - fontPx: number, - letterSpacingEm = 0, -): TextMetricsLite { - let width = text.length * fontPx * 0.6; - let ascent = fontPx * 0.8; - let descent = fontPx * 0.2; - - let ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null = null; - if (typeof OffscreenCanvas !== "undefined") { - ctx = new OffscreenCanvas(1, 1).getContext("2d"); - } else if (typeof document !== "undefined") { - const canvas = document.createElement("canvas"); - ctx = canvas.getContext("2d"); - } - - if (ctx) { - ctx.font = font; - const metrics = ctx.measureText(text); - width = metrics.width; - ascent = metrics.actualBoundingBoxAscent || ascent; - descent = metrics.actualBoundingBoxDescent || descent; - } +function shortSerial(value: string, head = 10, tail = 10): string { + const v = value.trim(); + if (v.length <= head + tail + 2) return v; + return `${v.slice(0, head)}…${v.slice(-tail)}`; +} - if (letterSpacingEm > 0 && text.length > 1) { - width += (text.length - 1) * letterSpacingEm * fontPx; - } +function badgeMark(ok: boolean): string { + if (ok) return "M18 32 L28 42 L46 18"; + return "M20 20 L44 44 M44 20 L20 44"; +} - return { width, ascent, descent }; +function escXml(s: string): string { + return s + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); } function dropUndefined>(value: T): T { @@ -89,19 +81,119 @@ function formatUsdValue(value: number | null | undefined): string { return fmtUsd(value); } -function sigilImageMarkup(sigilSvg: string | undefined, clipId: string): string { +function stripPhiPrefix(s: string): string { + return s.replace(/^\s*Φ+\s*/u, ""); +} + +function truncText(s: string, max: number): string { + const t = s.trim(); + if (t.length <= max) return t; + return `${t.slice(0, Math.max(0, max - 1))}…`; +} + +function asRecord(v: unknown): UnknownRecord | null { + return v && typeof v === "object" ? (v as UnknownRecord) : null; +} + +function readString(v: unknown): string | undefined { + return typeof v === "string" ? v : undefined; +} + +function readNumber(v: unknown): number | undefined { + return typeof v === "number" && Number.isFinite(v) ? v : undefined; +} + +function firstString(...vals: unknown[]): string | undefined { + for (const v of vals) { + if (typeof v === "string" && v.trim().length) return v.trim(); + } + return undefined; +} + +/** + * STRICT: "receive" only when the glyph/proof/embedded metadata proves it. + * Never infer receive from valuation flavor alone. + */ +function isReceiveGlyphStrict(data: VerifiedCardData, receiptPayload: unknown, valuationSnapshot: unknown): boolean { + const d = asRecord(data); + const r = asRecord(receiptPayload); + const v = asRecord(valuationSnapshot); + + const explicitMode = + readString(d?.["mode"]) ?? + readString(d?.["verifyMode"]) ?? + readString(d?.["verificationMode"]) ?? + readString(r?.["mode"]) ?? + readString(r?.["proofMode"]) ?? + readString(v?.["mode"]); + + if (explicitMode === "receive") return true; + if (explicitMode === "origin") return false; + + const embedded = + asRecord(d?.["embedded"]) ?? + asRecord(d?.["metadata"]) ?? + asRecord(d?.["sigilMeta"]) ?? + asRecord(d?.["sigilMetadata"]) ?? + asRecord(d?.["sigil"]); + + if (embedded) { + if (typeof embedded["childOfHash"] === "string" && embedded["childOfHash"].length > 0) return true; + if (readNumber(embedded["childIssuedPulse"]) !== undefined) return true; + if (typeof embedded["childAllocationPhi"] === "string" && embedded["childAllocationPhi"].length > 0) return true; + if (embedded["childClaim"] && typeof embedded["childClaim"] === "object") return true; + } + + return false; // strict default +} + +function rosettePath(seed: string): string { + const h = hashStringToInt(seed); + const cx = 600; + const cy = 332; + const R = 220; + const turns = 120; + + const wobble = 0.16 + ((h >>> 5) % 45) / 300; + const k1 = 3 + ((h >>> 9) % 4); + const k2 = 4 + ((h >>> 13) % 4); + const spinA = 5 + ((h >>> 17) % 5); + + let d = `M ${cx} ${cy - R}`; + for (let i = 1; i <= turns; i += 1) { + const t = (i / turns) * 2 * Math.PI; + const r = R * (1 - wobble + wobble * Math.sin(spinA * t)); + const x = cx + r * Math.sin(k1 * t); + const y = cy + r * Math.cos(k2 * t); + d += ` L ${x.toFixed(2)} ${y.toFixed(2)}`; + } + return `${d} Z`; +} + +function sigilSlotMarkup( + sigilSvg: string | undefined, + slot: { x: number; y: number; w: number; h: number; r: number }, + clipId: string, +): string { + const { x, y, w, h } = slot; + if (!sigilSvg) { return ` - - Sigil unavailable + + + SIGIL UNAVAILABLE + `; } + const sanitized = sanitizeSigilSvg(sigilSvg); const dataUri = svgToDataUri(sanitized); + return ` - QR unavailable + + + QR UNAVAILABLE + `; } return ` - + + + + + + SCAN • VERIFY + `; } -function shortHash(value: string | undefined, head = 10, tail = 8): string { - if (!value) return "—"; - if (value.length <= head + tail + 2) return value; - return `${value.slice(0, head)}…${value.slice(-tail)}`; -} +function kasG16BadgesMarkup(kasOk: boolean | null | undefined, g16Ok: boolean, x: number, y: number): string { + const hasKas = typeof kasOk === "boolean"; + const kasStroke = kasOk ? "#37e6d4" : "#ff7d7d"; + const g16Stroke = g16Ok ? "#37e6d4" : "#ff7d7d"; + + const badge = (label: string, ok: boolean, stroke: string, bx: number, by: number) => ` + + + ${escXml(label)} + + + + + + + + `; -function sealPlacement(seedValue: string): { x: number; y: number; rotation: number; dash: string } { - const hash = hashStringToInt(seedValue); - const offsetX = (hash % 48) - 24; - const offsetY = ((hash >> 6) % 24) - 12; - const rotation = ((hash % 41) - 20) * 0.5; - const dashA = 4 + (hash % 5); - const dashB = 3 + ((hash >> 3) % 4); - return { x: 1008 + offsetX, y: 92 + offsetY, rotation, dash: `${dashA} ${dashB}` }; -} + if (!hasKas) return badge("G16", g16Ok, g16Stroke, x, y); -function sealBrandIcon(x: number, y: number, palette: { primary: string; accent: string }): string { return ` - - - - + ${badge("KAS", Boolean(kasOk), kasStroke, x, y)} + ${badge("G16", g16Ok, g16Stroke, x + 156, y)} `; } -function ornamentMarkup( - x: number, - y: number, - width: number, - height: number, - palette: { primary: string; secondary: string; accent: string }, - geometry: { tickCount: number; polygonSides: number }, -): string { - const inset = 16; - const left = x + inset; - const right = x + width - inset; - const top = y + inset; - const bottom = y + height - inset; - const cx = x + width - inset - 24; - const cy = y + inset + 24; - const ticks = Array.from({ length: Math.min(geometry.tickCount, 18) }).map((_, idx) => { - const t = (idx / Math.min(geometry.tickCount, 18)) * Math.PI * 0.6 + Math.PI * 1.2; - const inner = 18; - const outer = 26; - const x1 = cx + Math.cos(t) * inner; - const y1 = cy + Math.sin(t) * inner; - const x2 = cx + Math.cos(t) * outer; - const y2 = cy + Math.sin(t) * outer; - return ``; - }); - const poly = Array.from({ length: geometry.polygonSides }).map((_, idx) => { - const t = (idx / geometry.polygonSides) * Math.PI * 2 - Math.PI / 2; - const px = cx + Math.cos(t) * 12; - const py = cy + Math.sin(t) * 12; - return `${idx === 0 ? "M" : "L"}${px.toFixed(2)} ${py.toFixed(2)}`; - }); - return ` - - - - - ${ticks.join("\n")} - - - `; +/** + * Text measurement (best effort): + * - Uses OffscreenCanvas/Canvas measureText when available + * - Falls back to conservative estimate (overestimates width to prevent overflow) + */ +function measureTextWidthPx(text: string, font: string, fontPx: number, letterSpacingPx = 0): number { + let width = text.length * (fontPx * 0.78) + Math.max(0, text.length - 1) * letterSpacingPx; + + let ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null = null; + if (typeof OffscreenCanvas !== "undefined") { + ctx = new OffscreenCanvas(1, 1).getContext("2d"); + } else if (typeof document !== "undefined") { + const c = document.createElement("canvas"); + ctx = c.getContext("2d"); + } + + if (ctx) { + ctx.font = font; + const m = ctx.measureText(text); + width = m.width + Math.max(0, text.length - 1) * letterSpacingPx; + } + + return width; +} + +function wrapTextToWidth(text: string, maxWidthPx: number, font: string, fontPx: number, letterSpacingPx = 0): string[] { + const clean = text.trim().replace(/\s+/g, " "); + if (!clean) return []; + + const words = clean.split(" "); + const lines: string[] = []; + let cur = ""; + + for (const w of words) { + const next = cur ? `${cur} ${w}` : w; + const wpx = measureTextWidthPx(next, font, fontPx, letterSpacingPx); + if (wpx <= maxWidthPx) { + cur = next; + continue; + } + if (cur) lines.push(cur); + cur = w; + } + + if (cur) lines.push(cur); + return lines; } export function buildVerifiedCardSvg(data: VerifiedCardData): string { + return buildKvrSvg(data, "proof"); +} + +export function buildRedeemableNoteSvg(data: VerifiedCardData): string { + return buildKvrSvg(data, "money"); +} + +function buildKvrSvg(data: VerifiedCardData, kind: CardKind): string { const { capsuleHash, verifiedAtPulse, phikey, kasOk, g16Ok, sigilSvg, qrDataUrl, svgHash, receiptHash } = data; + const { accent, accentSoft, accentGlow } = accentFromHash(capsuleHash); - const id = `og-${hashStringToInt(capsuleHash).toString(16)}`; + + const id = `kvr-${hashStringToInt(`${capsuleHash}|${verifiedAtPulse}|${kind}`).toString(16)}`; const sigilClipId = `${id}-sigil-clip`; const qrClipId = `${id}-qr-clip`; - const ringGradientId = `${id}-ring`; - const glowId = `${id}-glow`; - const waveId = `${id}-wave`; - const badgeGlowId = `${id}-badge-glow`; - const sealId = `${id}-seal`; - const hasKas = typeof kasOk === "boolean"; - const sealSeed = `${capsuleHash}|${svgHash ?? ""}|${verifiedAtPulse}`; - const seal = sealPlacement(sealSeed); + const legalClipId = `${id}-legal-clip`; const phiShort = shortPhiKey(phikey); - const valuationSnapshot = data.valuation ? { ...data.valuation } : data.receipt?.valuation ? { ...data.receipt.valuation } : undefined; + + const valuationSnapshot = + data.valuation ? { ...data.valuation } : data.receipt?.valuation ? { ...data.receipt.valuation } : undefined; + if (valuationSnapshot && "valuationHash" in valuationSnapshot) { delete (valuationSnapshot as { valuationHash?: string }).valuationHash; } + const valuationHash = data.valuation?.valuationHash ?? data.receipt?.valuationHash; - const valuationPhi = formatPhiValue(valuationSnapshot?.phiValue); + const valuationPhiRaw = formatPhiValue(valuationSnapshot?.phiValue); + const valuationPhiText = stripPhiPrefix(valuationPhiRaw); const valuationUsd = formatUsdValue(valuationSnapshot?.usdValue); - const isReceiveMode = valuationSnapshot?.mode === "receive"; - const valuationModeLabel = isReceiveMode ? "RECEIVE" : "ORIGIN"; - const headlineText = isReceiveMode ? "VERIFIED RECEIVE" : "VERIFIED ORIGIN"; + + const vrec = asRecord(valuationSnapshot); + const valuationPulse = + readNumber(vrec?.["pulse"]) ?? + readNumber(vrec?.["valuationPulse"]) ?? + readNumber(vrec?.["atPulse"]) ?? + readNumber(vrec?.["computedPulse"]); + + const valuationAlg = firstString(vrec?.["valuationAlg"], vrec?.["alg"], vrec?.["algorithm"], vrec?.["policyAlg"], vrec?.["policy"]); + const valuationStamp = firstString( + vrec?.["valuationStamp"], + vrec?.["stamp"], + vrec?.["hash"], + typeof valuationHash === "string" ? valuationHash : undefined, + ); const receiptPayload = data.receipt ?? @@ -233,29 +355,37 @@ export function buildVerifiedCardSvg(data: VerifiedCardData): string { verificationVersion: data.verificationVersion, } : undefined); - const receiptMeta: Record = {}; + const bundleHashValue = receiptPayload?.bundleHash ?? data.bundleHash; const zkPoseidonHash = receiptPayload?.zkPoseidonHash ?? data.zkPoseidonHash; const verifier = receiptPayload?.verifier ?? data.verifier; const verificationVersion = receiptPayload?.verificationVersion ?? data.verificationVersion; + + const isReceiveMode = isReceiveGlyphStrict(data, receiptPayload, valuationSnapshot); + const verifiedStampText = isReceiveMode ? "VERIFIED RECEIVE" : "VERIFIED ORIGIN"; + + const variantLine = + kind === "money" + ? isReceiveMode + ? "RECEIVE MONEY" + : "ORIGIN MONEY" + : isReceiveMode + ? "RECEIVE PROOF" + : "ORIGIN PROOF"; + + const receiptMeta: Record = {}; if (bundleHashValue) receiptMeta.bundleHash = bundleHashValue; if (zkPoseidonHash) receiptMeta.zkPoseidonHash = zkPoseidonHash; if (verifier) receiptMeta.verifier = verifier; if (verificationVersion) receiptMeta.verificationVersion = verificationVersion; receiptMeta.verifiedAtPulse = receiptPayload?.verifiedAtPulse ?? verifiedAtPulse; + receiptMeta.assetMode = isReceiveMode ? "receive" : "origin"; + receiptMeta.artifactKind = kind; if (receiptPayload) receiptMeta.receipt = receiptPayload; if (data.receiptHash) receiptMeta.receiptHash = data.receiptHash; if (data.verificationSig) receiptMeta.verificationSig = data.verificationSig; const receiptJson = JSON.stringify(receiptMeta); - const proofSeal = buildProofOfBreathSeal({ - bundleHash: bundleHashValue, - capsuleHash, - svgHash, - receiptHash, - pulse: verifiedAtPulse, - }); - const auditMeta = dropUndefined({ receiptHash: data.receiptHash, valuation: valuationSnapshot, @@ -263,217 +393,383 @@ export function buildVerifiedCardSvg(data: VerifiedCardData): string { bundleHash: bundleHashValue, zkPoseidonHash, verifiedAtPulse: receiptPayload?.verifiedAtPulse ?? verifiedAtPulse, + artifactKind: kind, + assetMode: isReceiveMode ? "receive" : "origin", }); const auditJson = JSON.stringify(auditMeta); + const proofSeal = buildProofOfBreathSeal({ + bundleHash: bundleHashValue, + capsuleHash, + svgHash, + receiptHash, + pulse: verifiedAtPulse, + }); + + const slot = { x: 360, y: 198, w: 480, h: 300, r: 18 }; + const slotCenterX = slot.x + slot.w / 2; + + // Proof badge down slightly; QR moved left + const proofSealSize = 168; + const proofSealX = 960; + const proofSealY = 144; const proofSealLabel = "PROOF OF BREATH™"; const proofSealMicro = `${shortHash(capsuleHash, 6, 4)} · ${shortHash(bundleHashValue, 6, 4)}`; - const proofSealSizeDefault = 240; - const proofSealRadiusDefault = Math.floor(proofSealSizeDefault / 2); - const proofSealRadiusMin = 120; - const proofSealXDefault = 600; - const proofSealY = 312; - const sigilFrameX = 796; - const sigilFrameY = 136; - const sigilFrameSize = 348; - const unit = 22; - const phiGap = Math.round(unit * PHI); - const titleY = 134; - const subheadY = titleY + 42; - const modeLabelY = subheadY + 34; - const badgeLabelY = modeLabelY + Math.round(unit * 2.15); - const valueLabelY = badgeLabelY + phiGap; - const valueY = valueLabelY + Math.round(unit * 1.4); - const usdLabelY = valueY + phiGap; - const usdValueY = usdLabelY + Math.round(unit * 1.4); - const qrBoxX = 128; - const qrBoxY = usdValueY + Math.round(phiGap * 0.45); - const qrBoxW = 288; - const qrBoxH = 140; - const brandX = VERIFIED_CARD_W / 2; - const brandY = 78; - const brandText = "SIGIL-SEAL"; - const brandFontPx = 14; - const brandLetterSpacingEm = 0.42; - const brandFont = `600 ${brandFontPx}px Inter, "Segoe UI", "Helvetica Neue", Arial, sans-serif`; - const brandMetrics = measureTextMetrics(brandText, brandFont, brandFontPx, brandLetterSpacingEm); - const brandIconSize = 12; - const brandIconGap = Math.round(brandFontPx * 0.6); - const brandGroupWidth = brandMetrics.width + brandIconSize + brandIconGap; - const brandTextX = brandX - brandGroupWidth / 2 + brandIconSize + brandIconGap; - const brandIconX = brandX - brandGroupWidth / 2 + brandIconSize / 2; - const labelFontPx = 22; - const labelLetterSpacingEm = 0.08; - const labelFont = `700 ${labelFontPx}px Inter, "Segoe UI", "Helvetica Neue", Arial, sans-serif`; - const g16LabelX = hasKas ? 470 : 320; - const g16Metrics = measureTextMetrics("G16", labelFont, labelFontPx, labelLetterSpacingEm); - const g16BadgeSize = Math.round(labelFontPx * 1.25); - const g16BadgeGap = Math.round(labelFontPx * 0.45); - const g16BadgeX = g16LabelX + g16Metrics.width + g16BadgeGap; - const g16BadgeY = badgeLabelY - Math.round(Math.max(g16Metrics.ascent * 0.9, g16BadgeSize * 0.85)); - const g16BadgeRadius = Math.round(g16BadgeSize * 0.26); - const g16BadgeStroke = Math.max(1.4, g16BadgeSize * 0.033); - const g16MarkScale = (g16BadgeSize / 54) * 0.5; - const g16MarkTranslate = (g16BadgeSize / 54) * 13.5; - const valueFontPx = 30; - const valueFont = `700 ${valueFontPx}px Inter, "Segoe UI", "Helvetica Neue", Arial, sans-serif`; - const valueMetrics = measureTextMetrics(valuationPhi, valueFont, valueFontPx); - const usdMetrics = measureTextMetrics(valuationUsd, valueFont, valueFontPx); - const leftValueRightEdge = 320 + Math.max(valueMetrics.width, usdMetrics.width); - const safeGap = 24; - const badgeLeftBound = leftValueRightEdge + safeGap; - let proofSealX = proofSealXDefault; - let allowedRadius = proofSealX - badgeLeftBound; - let proofSealRadius = Math.min(proofSealRadiusDefault, allowedRadius); - if (proofSealRadius < proofSealRadiusMin) { - const neededShift = Math.min(24, proofSealRadiusMin - proofSealRadius); - proofSealX += neededShift; - allowedRadius = proofSealX - badgeLeftBound; - proofSealRadius = Math.min(proofSealRadiusDefault, allowedRadius); - } - proofSealRadius = Math.max(0, proofSealRadius); - const proofSealSize = proofSealRadius * 2; const proofSealMarkup = proofSeal.toSvg(proofSealX, proofSealY, proofSealSize, id, proofSealLabel, proofSealMicro); - const phiKeyLabelY = Math.round(proofSealY + proofSealSize / 2 + phiGap * 0.7); - const phiKeyValueY = phiKeyLabelY + Math.round(unit * 1.9); - const brandPalette = proofSeal.palette; - const ornament = ornamentMarkup(810, 150, 320, 320, brandPalette, proofSeal.geometry); + + const qrX = 980; + const qrY = 344; + + const rosette = rosettePath(`${capsuleHash}|${svgHash ?? ""}|${verifiedAtPulse}|${kind}`); + + const serialCore = (zkPoseidonHash ? zkPoseidonHash.slice(0, 12).toUpperCase() : capsuleHash.slice(0, 12).toUpperCase()).replace( + /[^0-9A-F]/g, + "Φ", + ); + const serial = `KVR-${serialCore}-${String(verifiedAtPulse)}`; + const serialDisplay = shortSerial(serial, 10, 10); + + const microLine = escXml( + `KAIROS KURRENSY • LAWFUL UNDER YAHUAH • Φ • ${variantLine} • ${verifiedStampText} • STEWARD VERIFIED @ PULSE ${verifiedAtPulse} • SERIAL ${serial} • BUNDLE ${shortHash(bundleHashValue, 10, 8)} • RECEIPT ${shortHash(receiptHash, 10, 8)}`, + ); + + const drec = asRecord(data); + const embedded = + asRecord(drec?.["embedded"]) ?? + asRecord(drec?.["metadata"]) ?? + asRecord(drec?.["sigilMeta"]) ?? + asRecord(drec?.["sigilMetadata"]) ?? + asRecord(drec?.["sigil"]); + + const childOfHash = readString(embedded?.["childOfHash"]); + const childClaim = asRecord(embedded?.["childClaim"]); + const claimSteps = readNumber(childClaim?.["steps"]); + const claimExpireAtPulse = readNumber(childClaim?.["expireAtPulse"]); + + const moneyRedeemTitle = isReceiveMode ? "REDEEMABLE CLAIM — RECEIVE NOTE" : "REDEEMABLE CLAIM — ORIGIN NOTE"; + const moneyRedeemPolicy = valuationHash ? `POLICY HASH ${shortHash(String(valuationHash))}` : "POLICY HASH —"; + const moneyRedeemLineage = childOfHash ? `LINEAGE ${shortHash(childOfHash, 10, 8)}` : "LINEAGE —"; + const moneyRedeemWindow = + claimSteps !== undefined && claimExpireAtPulse !== undefined + ? `CLAIM WINDOW ${claimSteps} STEPS • EXPIRES @ PULSE ${claimExpireAtPulse}` + : "CLAIM WINDOW —"; + + const headerY1 = 78; + const headerY2 = 102; + const stampY = 152; + const stewardY = 178; + const variantY = 202; + + const leftValueLabel = kind === "money" ? "VALUE" : "Φ VALUE"; + + // Bottom panel + footer spacing fixes + const belt1Y = 556; + const belt2Y = 574; + + // Valuation + footer pushed up and spaced so nothing overlaps + const valuation1Y = 548; // moved up a lot (kept on-card) + const valuation2Y = 564; // spacing from valuation1Y + + const footerTopY = 588; // moved up + const footerRow1Y = footerTopY; + const footerRow2Y = footerTopY + 16; // enough separation + + // Caption + legal + const slotBottom = slot.y + slot.h; + const panelTop = slotBottom + Math.round(4 * PHI); + const captionY = Math.round(panelTop + Math.round(7 * PHI)); + const captionGap = Math.round(4 * PHI); + + const legalFontCandidates: Array<{ fontPx: number; lineH: number }> = [ + { fontPx: 11, lineH: 13 }, + { fontPx: 10, lineH: 12 }, + { fontPx: 9, lineH: 11 }, + { fontPx: 8, lineH: 10 }, + ]; + + const legalText = + kind === "money" + ? `This note is redeemable in Kairos under Yahuah’s Law by the stated policy and claim window. Redemption is steward-verified and bound to the ΦKey and proof bundle embedded in this note.` + : `This Sigil-Seal is proof of stewardship in Kairos under Yahuah’s Law. It attests that the steward verified the embedded proof bundle at the stated pulse. It is a verification instrument, not a redeemable note.`; + + const legalOuterWMax = 640; + const legalOuterWMin = 420; + const legalPadX = Math.round(8 * PHI); + const legalPadY = Math.round(3 * PHI); + + const legalLeftBound = 280; + const legalRightBound = qrX - Math.round(2 * PHI); + const maxPossibleW = Math.max(legalOuterWMin, Math.min(legalOuterWMax, legalRightBound - legalLeftBound)); + + const legalOuterW = maxPossibleW; + const legalOuterX = Math.round(Math.max(legalLeftBound, Math.min(legalRightBound - legalOuterW, slotCenterX - legalOuterW / 2))); + + const legalY = Math.round(Math.max(captionY + captionGap, panelTop + Math.round(8 * PHI))); + const legalMaxH = Math.max(0, belt1Y - Math.round(4 * PHI) - legalY); + + const legalInnerW = Math.max(0, legalOuterW - legalPadX * 2); + let chosenFontPx = 9; + let chosenLineH = 11; + let legalLinesRaw: string[] = []; + + for (const cand of legalFontCandidates) { + const font = `600 ${cand.fontPx}px Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Arial`; + const lines = wrapTextToWidth(legalText, legalInnerW, font, cand.fontPx, 0); + const neededH = legalPadY * 2 + lines.length * cand.lineH; + if (neededH <= legalMaxH) { + chosenFontPx = cand.fontPx; + chosenLineH = cand.lineH; + legalLinesRaw = lines; + break; + } + } + + if (legalLinesRaw.length === 0) { + const cand = legalFontCandidates[legalFontCandidates.length - 1]; + const font = `600 ${cand.fontPx}px Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Arial`; + chosenFontPx = cand.fontPx; + chosenLineH = cand.lineH; + legalLinesRaw = wrapTextToWidth(legalText, legalInnerW, font, cand.fontPx, 0); + } + + const legalLines = legalLinesRaw.map(escXml); + const legalH = Math.min(legalMaxH, legalPadY * 2 + legalLinesRaw.length * chosenLineH); + const textBlockH = legalLinesRaw.length * chosenLineH; + const textTopY = legalY + Math.max(legalPadY, Math.floor((legalH - textBlockH) / 2)); + const legalCenterX = legalOuterX + legalOuterW / 2; + const slotCaptionY = Math.min(captionY, legalY - Math.round(2 * PHI)); + + const showValuationMeta = valuationPulse !== undefined || Boolean(valuationAlg) || Boolean(valuationStamp); + + // Footer row x spacing (more space; no overlap) + const fx1 = 72; + const fx2 = 460; + const fx3 = 860; + return ` - + ${receiptJson} ${auditJson} + - - - - + + + - - - - + + + + - + + - - - - - - - + + + + ${microLine} + + + + + + + + + - - - - - - + + + + - - + + + + - + + + + + + - + + + + + - + - - + ${escXml(NOTE_TITLE_TEXT)} + ${escXml(NOTE_SUBTITLE_TEXT)} - - - - - - - + ${escXml(verifiedStampText)} + Steward Verified @ Pulse ${escXml(String(verifiedAtPulse))} + ${escXml(variantLine)} - - - + + - ${sealBrandIcon(brandIconX, brandY - 4, { primary: brandPalette.primary, accent: brandPalette.accent })} - ${brandText} + + + + ${escXml(leftValueLabel)} + + ${escXml(valuationPhiText)} - ${headlineText} - Steward Verified @ Pulse ${verifiedAtPulse} - ${valuationModeLabel ? `${valuationModeLabel}` : ""} + USD VALUE + ${escXml(valuationUsd)} - ΦKEY - ${phiShort} + ΦKEY + ${escXml(phiShort)} - ${hasKas ? `KAS` : ""} - ${ - hasKas - ? ` - - - + SERIAL ${escXml(serialDisplay)} + SVG ${escXml(shortHash(svgHash))} + SEAL ${escXml(shortHash(capsuleHash))} + + + + ${kasG16BadgesMarkup(kasOk, g16Ok, 0, 0)} - ` - : "" - } - G16 - - - - + ${ + kind === "money" + ? ` + + + ${escXml(moneyRedeemTitle)} + ${escXml(moneyRedeemPolicy)} + ${escXml(moneyRedeemWindow)} + ${escXml(moneyRedeemLineage)} + ` + : "" + } - Φ VALUE - ${valuationPhi} + + + + + + ${sigilSlotMarkup(sigilSvg, slot, sigilClipId)} - USD VALUE - ${valuationUsd} + SIGIL • SEALED • VERIFIED + - - - - VERIFIED - ${shortHash(capsuleHash, 6, 4)} + + ${proofSealMarkup} + ${qrBlockMarkup(qrDataUrl, qrX, qrY, qrClipId)} + + + + + + ${ + legalLines.length + ? legalLines + .map((line, i) => { + const y = textTopY + chosenLineH * (i + 1); + return `${line}`; + }) + .join("\n") + : "" + } + - ${proofSealMarkup} + + + + + + Φ + - - - ${sigilImageMarkup(sigilSvg, sigilClipId)} - ${ornament} - - ${qrImageMarkup(qrDataUrl, qrClipId, qrBoxX + 12, qrBoxY + 8)} + ${ + showValuationMeta + ? ` + + ${ + valuationPulse !== undefined || valuationAlg + ? `VALUATION${valuationPulse !== undefined ? ` PULSE ${escXml(String(valuationPulse))}` : ""}${ + valuationPulse !== undefined && valuationAlg ? " • " : "" + }${valuationAlg ? `ALGORITHM ${escXml(truncText(valuationAlg, 44))}` : ""}` + : "" + } + ${ + valuationStamp + ? `HASH (VALUATION) ${escXml(shortHash(valuationStamp, 14, 10))}` + : "" + } + + ` + : "" + } + + + + BUNDLE ${escXml(shortHash(bundleHashValue))} + ZK ${escXml(shortHash(zkPoseidonHash))} + VERIFIER ${escXml(verifier ?? "—")} - - BUNDLE ${shortHash(bundleHashValue)} - RECEIPT ${shortHash(receiptHash)} - SVG ${shortHash(svgHash)} - CAPSULE ${shortHash(capsuleHash)} - phi.network + RECEIPT ${escXml(shortHash(receiptHash))} + VERSION ${escXml(verificationVersion ?? "—")} + phi.network `.trim(); diff --git a/src/og/sigilEmbed.ts b/src/og/sigilEmbed.ts index b8c32226..3f3d3415 100644 --- a/src/og/sigilEmbed.ts +++ b/src/og/sigilEmbed.ts @@ -23,7 +23,7 @@ function stripUnsafeHrefs(svg: string): string { return svg.replace(JS_PROTOCOL_ATTR, (match, attr, value) => { const sanitized = sanitizeHrefValue(String(value)); if (!sanitized) return ""; - return ` ${String(attr)}=\"${sanitized}\"`; + return ` ${String(attr)}="${sanitized}"`; }); } diff --git a/src/version.ts b/src/version.ts index 102d536f..bd05b495 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,7 +1,7 @@ // src/version.ts // Shared PWA version constants so the app shell, SW registration, and UI stay in sync. -export const BASE_APP_VERSION = "42.3.0"; // Canonical offline/PWA version +export const BASE_APP_VERSION = "42.4.0"; // Canonical offline/PWA version export const SW_VERSION_EVENT = "kairos:sw-version"; export const DEFAULT_APP_VERSION = BASE_APP_VERSION; // Keep in sync with public/sw.js const ENV_APP_VERSION =