diff --git a/public/sw.js b/public/sw.js index 2825fdb46..b69e80853 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.4.0"; // update on release +const APP_VERSION = "42.7.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/public/verifier.html b/public/verifier.html index 1d816adfa..573a39cd8 100644 --- a/public/verifier.html +++ b/public/verifier.html @@ -2465,7 +2465,7 @@

Inhale • Verify • Remember • Exhale

} } function buildBanknoteSVG(){ - const NOTE_TITLE = "KAIROS NOTE — LEGAL TENDER OF THE SOVEREIGN KINGDOM"; + const NOTE_TITLE = "☤KAIROS NOTE — LEGAL TENDER OF THE SOVEREIGN KINGDOM"; /* Inputs */ const purposeRaw = $("note-purpose").value || ""; diff --git a/server.mjs b/server.mjs index 4333c44f8..f40c8aaf7 100644 --- a/server.mjs +++ b/server.mjs @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import fsSync from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { PassThrough } from "node:stream"; +import { PassThrough, Readable } from "node:stream"; import { createServer as createHttpServer } from "node:http"; import { createServer as createViteServer } from "vite"; import { createHash } from "node:crypto"; @@ -32,6 +32,9 @@ const OG_PATH_PREFIX = "/og/v/verified/"; const OG_CACHE_CONTROL = "public, max-age=0, s-maxage=31536000, immutable"; const OG_CACHE_TTL_MS = 10 * 60 * 1000; const OG_CACHE_MAX_ENTRIES = 512; +const SIGILS_PROXY_PATH = "/sigils"; +const SIGILS_PRIMARY_BASE = "https://m.kai.ac"; +const SIGILS_BACKUP_BASE = "https://memory.kaiklok.com"; const escapeHtml = (value) => String(value) @@ -79,6 +82,84 @@ const tryServeStatic = (req, res, rootDir) => { return true; }; +const shouldFailoverStatus = (status) => { + if (status === 0) return true; + if (status === 404) return true; + if (status === 408 || status === 429) return true; + if (status >= 500) return true; + return false; +}; + +const readRequestBody = async (req) => { + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + } + return chunks.length ? Buffer.concat(chunks) : undefined; +}; + +const buildProxyHeaders = (req) => { + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + const lower = key.toLowerCase(); + if (lower === "host" || lower === "connection" || lower === "content-length") continue; + if (typeof value === "undefined") continue; + if (Array.isArray(value)) { + for (const entry of value) headers.append(lower, entry); + } else { + headers.set(lower, value); + } + } + return headers; +}; + +const proxySigils = async (req, res) => { + if (!req.url?.startsWith(SIGILS_PROXY_PATH)) return false; + + const body = + req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS" + ? undefined + : await readRequestBody(req); + const headers = buildProxyHeaders(req); + const bases = [SIGILS_PRIMARY_BASE, SIGILS_BACKUP_BASE]; + let lastStatus = null; + + for (const base of bases) { + try { + const proxyRes = await fetch(`${base}${req.url}`, { + method: req.method, + headers, + body, + }); + + if (shouldFailoverStatus(proxyRes.status) && base === SIGILS_PRIMARY_BASE) { + lastStatus = proxyRes.status; + continue; + } + + res.statusCode = proxyRes.status; + for (const [key, value] of proxyRes.headers) { + if (key.toLowerCase() === "content-encoding") continue; + res.setHeader(key, value); + } + + if (proxyRes.body) { + Readable.fromWeb(proxyRes.body).pipe(res); + return true; + } + + res.end(); + return true; + } catch { + continue; + } + } + + res.statusCode = lastStatus ?? 502; + res.end("Bad Gateway"); + return true; +}; + async function createServer() { let vite; if (!isProd) { @@ -243,6 +324,8 @@ async function createServer() { return; } + if (await proxySigils(req, res)) return; + if (await handleOgRoute(req, res)) return; if (isProd) { diff --git a/src/App.tsx b/src/App.tsx index 247fa2e4d..417ad6fb2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -321,6 +321,19 @@ const DEFAULT_LIVE_SNAP: LiveKaiSnap = { dmyLabel: formatDMYLabel(DEFAULT_BEAT_STEP_DMY), chakraDay: "Heart", }; +function isEditableElement(el: Element | null): boolean { + if (!el) return false; + if (el instanceof HTMLInputElement) return !el.disabled; + if (el instanceof HTMLTextAreaElement) return !el.disabled; + if (el instanceof HTMLSelectElement) return !el.disabled; + if (el instanceof HTMLElement && el.isContentEditable) return true; + return false; +} + +function isEditingNow(): boolean { + if (typeof document === "undefined") return false; + return isEditableElement(document.activeElement as Element | null); +} function computeBeatStepDMY(m: KaiMoment): BeatStepDMY { const pulse = readNum(m, "pulse") ?? 0; @@ -441,8 +454,40 @@ function ExplorerPopover({ children, }: ExplorerPopoverProps): React.JSX.Element | null { const hydrated = useHydrated(); - const vvSizeRaw = useVisualViewportSize(); - const vvSize = hydrated ? vvSizeRaw : { width: 0, height: 0 }; +const vvSizeRaw = useVisualViewportSize(); + +// ✅ Freeze viewport height while typing (prevents iOS keyboard resize thrash) +const [vvStable, setVvStable] = useState<{ width: number; height: number }>({ width: 0, height: 0 }); + +useIsoLayoutEffect(() => { + if (!hydrated) return; + setVvStable({ width: vvSizeRaw.width, height: vvSizeRaw.height }); + // eslint-disable-next-line react-hooks/exhaustive-deps +}, [hydrated]); + +useEffect(() => { + if (!hydrated) return; + if (isEditingNow()) return; // ✅ do not update while typing + setVvStable((prev) => { + if (prev.width === vvSizeRaw.width && prev.height === vvSizeRaw.height) return prev; + return { width: vvSizeRaw.width, height: vvSizeRaw.height }; + }); +}, [hydrated, vvSizeRaw.width, vvSizeRaw.height]); + +useEffect(() => { + if (!hydrated) return; + + const onFocusOut = (): void => { + // when keyboard closes, resync once + setVvStable({ width: vvSizeRaw.width, height: vvSizeRaw.height }); + }; + + document.addEventListener("focusout", onFocusOut, true); + return () => document.removeEventListener("focusout", onFocusOut, true); +}, [hydrated, vvSizeRaw.width, vvSizeRaw.height]); + +const vvSize = hydrated ? vvStable : { width: 0, height: 0 }; + const portalHost = useMemo(() => { if (!hydrated) return null; diff --git a/src/components/ExhaleNote.css b/src/components/ExhaleNote.css index 204515041..a8505daf3 100644 --- a/src/components/ExhaleNote.css +++ b/src/components/ExhaleNote.css @@ -1,505 +1,1091 @@ /* ───────────────────────────────────────────────────────────────────────────── - noteExhaler.css - Atlantean-grade UI — refined, official, and deeply accessible - Pairs with: src/components/ExhaleNote.tsx + ExhaleNote.css — Atlantean Glass Note Composer (v26.3) + FINAL PRODUCTION STYLES — UNIT TOGGLE EDITION + - Premium one-row header (kk-headbar) with crystalline icon pills + - Guided step composer (top answer box beside Send Amount) + - Send Amount unit toggle (Φ / $) — polished segmented control + - Chat shows only past Q/A + current question (no future prompts) + - Preview card + classic form compatibility + - Scoped tokens to .kk-note (no global bleed) ───────────────────────────────────────────────────────────────────────────── */ -/* --- Theme Tokens ---------------------------------------------------------- */ - -:root { - --kk-bg: #0d0f12; - --kk-surface: #12151b; - --kk-surface-2: #171b22; - --kk-text: #eaf1ff; - --kk-text-dim: #b9c4d9; - --kk-text-mute: #8592aa; - - --kk-accent: #56ffe3; /* Atlantean teal (living water) */ - --kk-accent-2: #9ef7ff; /* soft cyan highlight */ - --kk-gold: #f5d98d; /* sacred gold */ - --kk-amber: #ffdba6; - --kk-emerald: #3de1a7; - --kk-crystal: rgba(255, 255, 255, 0.06); - - --kk-border: rgba(255, 255, 255, 0.09); - --kk-ring: rgba(150, 255, 228, 0.45); - - --kk-font-ui: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", "Apple Color Emoji", "Segoe UI Emoji"; - --kk-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - - --kk-radius-sm: 10px; - --kk-radius: 14px; - --kk-radius-lg: 18px; - --kk-shadow-1: 0 10px 30px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.03); - --kk-shadow-2: 0 18px 44px rgba(0,0,0,0.36), inset 0 1px 0 rgba(255,255,255,0.05); - - --kk-grad-hero: radial-gradient(1200px 800px at 85% -10%, rgba(86,255,227,0.12), transparent 55%), - radial-gradient(900px 700px at -20% 120%, rgba(159,247,255,0.10), transparent 45%), - linear-gradient(180deg, rgba(255,255,255,0.015), transparent); - - --kk-chip-live: linear-gradient(180deg, rgba(61,225,167,0.25), rgba(61,225,167,0.15)); - --kk-chip-locked: linear-gradient(180deg, rgba(245,217,141,0.28), rgba(245,217,141,0.16)); - - --kk-btn-grad: linear-gradient(180deg, #93ffe9, #49ffd7); - --kk-btn-grad-press: linear-gradient(180deg, #7dffe3, #2ef8c7); - - --kk-focus: 0 0 0 3px var(--kk-ring), 0 0 24px rgba(86,255,227,0.25); - } - - - - /* --- Base & Layout --------------------------------------------------------- */ - - .kk-note { - font-family: var(--kk-font-ui); - color: var(--kk-text); - background: var(--kk-bg); - border: 1px solid var(--kk-border); - border-radius: var(--kk-radius-lg); - box-shadow: var(--kk-shadow-2); - padding: 18px; - overflow: hidden; - } - - .kk-note * { - box-sizing: border-box; - } - - .kk-bar { - display: flex; - gap: 12px; - align-items: center; - justify-content: space-between; - padding: 14px 16px; - background: linear-gradient(180deg, var(--kk-surface-2), transparent); - border: 1px solid var(--kk-border); - border-radius: var(--kk-radius); - box-shadow: var(--kk-shadow-1); - } - - .kk-brand { - letter-spacing: 0.4px; - text-transform: uppercase; - font-weight: 700; - color: var(--kk-text); - } - - .kk-legal-pill { - white-space: nowrap; - font-variant-numeric: slashed-zero; - font-size: 12px; - padding: 6px 10px; - border-radius: 999px; - background: linear-gradient(180deg, rgba(255,255,255,0.05), rgba(255,255,255,0.02)); - border: 1px solid var(--kk-border); - color: var(--kk-text-dim); - } - - /* --- Hero / Valuation ------------------------------------------------------ */ - - .kk-hero { - position: relative; - margin-top: 16px; - padding: 18px 18px 14px; - background: var(--kk-surface); - background-image: var(--kk-grad-hero); - border: 1px solid var(--kk-border); - border-radius: var(--kk-radius-lg); - box-shadow: var(--kk-shadow-2); - overflow: hidden; - isolation: isolate; - } - - .kk-hero::before { - content: ""; - position: absolute; - inset: 0; - background: - conic-gradient(from 180deg at 50% 0%, rgba(86,255,227,0.12), transparent 30%, transparent 70%, rgba(245,217,141,0.10)), - radial-gradient(800px 300px at 50% -10%, rgba(255,255,255,0.05), transparent 60%); - pointer-events: none; - mix-blend-mode: overlay; - opacity: 0.7; - } - - .kk-hero.is-live { outline: 1px solid rgba(61,225,167,0.2); } - .kk-hero.is-locked { outline: 1px solid rgba(245,217,141,0.2); } - - .kk-status { - display: flex; - flex-wrap: wrap; - gap: 8px; - align-items: center; - margin-bottom: 12px; - } - - .kk-chip { - font-size: 12px; - color: var(--kk-text); - border: 1px solid var(--kk-border); - padding: 6px 10px; - border-radius: 999px; - background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02)); - letter-spacing: 0.3px; - display: inline-flex; - align-items: center; - gap: 6px; - } - - .kk-chip-pulse { - font-variant-numeric: tabular-nums; - } - - .chip-live { - background: var(--kk-chip-live); - border-color: rgba(61,225,167,0.35); - box-shadow: 0 0 0 0 rgba(61,225,167,0.32); - animation: kkPulse 2.6s ease-in-out infinite; - } - - .chip-locked { - background: var(--kk-chip-locked); - border-color: rgba(245,217,141,0.38); - } - - @keyframes kkPulse { - 0% { box-shadow: 0 0 0 0 rgba(61,225,167,0.38); } - 70% { box-shadow: 0 0 0 10px rgba(61,225,167,0); } - 100% { box-shadow: 0 0 0 0 rgba(61,225,167,0); } - } - - .kk-value-row { - display: grid; - grid-template-columns: 1fr minmax(280px, 38%); - gap: 16px; - align-items: center; - } - - @media (max-width: 980px) { - .kk-value-row { grid-template-columns: 1fr; } - } - - .kk-value-block { - background: linear-gradient(180deg, var(--kk-crystal), transparent 60%); - border: 1px solid var(--kk-border); - border-radius: var(--kk-radius); - padding: 14px 16px; - box-shadow: var(--kk-shadow-1); - } - - .kk-value-label { - font-size: 12px; - letter-spacing: 3px; - text-transform: uppercase; - color: var(--kk-text-mute); - margin-bottom: 6px; - } - - .kk-value { - display: flex; - align-items: flex-end; - gap: 10px; - line-height: 1; - } - - .kk-value-sigil { - font-weight: 800; - font-size: clamp(28px, 5vw, 44px); - letter-spacing: -0.02em; - background: linear-gradient(180deg, var(--kk-accent), var(--kk-accent-2)); - -webkit-background-clip: text; - background-clip: text; - color: transparent; - filter: drop-shadow(0 6px 22px rgba(86,255,227,0.25)); - } - - .kk-value-int { - font-variant-numeric: tabular-nums; - font-feature-settings: "tnum"; - font-weight: 800; - font-size: clamp(40px, 8vw, 68px); - letter-spacing: -0.02em; - } - - .kk-value-frac { - font-variant-numeric: tabular-nums; - font-size: clamp(18px, 3.2vw, 28px); - color: var(--kk-text-dim); - padding-bottom: 4px; - } - - .kk-value-usd { - margin-top: 6px; - font-size: 14px; - color: var(--kk-text-mute); - } - - .kk-cta { - display: grid; - gap: 10px; - align-content: start; - } - - .kk-locked-banner { - border: 1px dashed rgba(245,217,141,0.5); - background: linear-gradient(180deg, rgba(245,217,141,0.10), rgba(245,217,141,0.06)); - border-radius: var(--kk-radius); - padding: 10px 12px; - } - - .kk-locked-title { - font-weight: 700; - color: var(--kk-gold); - letter-spacing: 0.3px; - } - - .kk-locked-sub { - margin-top: 2px; - font-size: 13px; - color: var(--kk-text-dim); - font-family: var(--kk-font-mono); - word-break: break-word; - } - - /* --- Buttons --------------------------------------------------------------- */ - - .kk-btn { - appearance: none; - border: 1px solid var(--kk-border); - background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02)); - color: var(--kk-text); - border-radius: 12px; - padding: 10px 14px; - font-weight: 600; - letter-spacing: 0.2px; - transition: transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease; - cursor: pointer; - box-shadow: var(--kk-shadow-1); - } - - .kk-btn:hover { - border-color: rgba(255,255,255,0.20); - transform: translateY(-1px); - } - - .kk-btn:active { - transform: translateY(0); - box-shadow: 0 6px 14px rgba(0,0,0,0.16) inset; - } - - .kk-btn[disabled] { - opacity: 0.6; - cursor: not-allowed; - } - - .kk-btn-primary { - border-color: rgba(86,255,227,0.5); - background: var(--kk-btn-grad); - color: #00241e; - box-shadow: 0 16px 36px rgba(86,255,227,0.25), inset 0 1px 0 rgba(255,255,255,0.4); - } - - .kk-btn-primary:hover { - box-shadow: 0 20px 44px rgba(86,255,227,0.32), inset 0 1px 0 rgba(255,255,255,0.5); - } - - .kk-btn-primary:active { - background: var(--kk-btn-grad-press); - } - - .kk-btn-ghost { - background: transparent; - border-color: rgba(255,255,255,0.16); - } - - .kk-btn-xl { - padding: 14px 18px; - font-size: 16px; - border-radius: 14px; - } - - /* --- Forms ----------------------------------------------------------------- */ - - .kk-row { - display: grid; - grid-template-columns: 160px 1fr; - gap: 12px; - align-items: center; - margin-top: 14px; - } - - @media (max-width: 700px) { - .kk-row { - grid-template-columns: 1fr; - } - } - - .kk-grid { - display: grid; - gap: 14px; - grid-template-columns: 1fr 1fr; - } - - @media (max-width: 900px) { - .kk-grid { grid-template-columns: 1fr; } - } - - .kk-stack { - display: grid; - gap: 12px; - } - - .kk-row > label { - color: var(--kk-text-mute); - font-size: 13px; - letter-spacing: 0.2px; - } - - .kk-row input, - .kk-row textarea { - width: 100%; - color: var(--kk-text); - background: var(--kk-surface-2); - border: 1px solid var(--kk-border); - border-radius: 12px; - padding: 10px 12px; - outline: none; - transition: box-shadow 120ms ease, border-color 120ms ease, background 120ms ease; - box-shadow: inset 0 1px 0 rgba(255,255,255,0.03); - } - - .kk-row textarea { - min-height: 120px; - resize: vertical; - } - - .kk-row input::placeholder, - .kk-row textarea::placeholder { - color: var(--kk-text-mute); - } - - .kk-row input:focus, - .kk-row textarea:focus { - border-color: rgba(86,255,227,0.6); - box-shadow: var(--kk-focus); - background: linear-gradient(180deg, rgba(86,255,227,0.05), var(--kk-surface-2)); - } - - .kk-row input[disabled], - .kk-row textarea[disabled] { - opacity: 0.7; - filter: saturate(0.85); - cursor: not-allowed; - } - - .kk-out { - font-family: var(--kk-font-mono); - background: linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.02)); - } - - /* --- Actions --------------------------------------------------------------- */ - - .kk-actions { - margin-top: 12px; - } - - .kk-flex { - display: inline-flex; - gap: 10px; - align-items: center; - justify-content: flex-end; - } - - /* --- Preview --------------------------------------------------------------- */ - - .kk-note-preview { - margin-top: 16px; - border-radius: var(--kk-radius-lg); - border: 1px solid var(--kk-border); - background: repeating-linear-gradient( - 45deg, - rgba(255,255,255,0.015), - rgba(255,255,255,0.015) 8px, - rgba(0,0,0,0.02) 8px, - rgba(0,0,0,0.02) 16px - ), - var(--kk-surface); - min-height: 280px; - box-shadow: var(--kk-shadow-2); - overflow: hidden; - position: relative; - } - - .kk-note-preview::after { - /* tasteful glint across preview */ - content: ""; - position: absolute; - inset: 0; - background: linear-gradient(110deg, transparent 40%, rgba(255,255,255,0.06), transparent 60%); - transform: translateX(-100%); - animation: kkGlint 3.8s ease-in-out infinite; - pointer-events: none; - } - - @keyframes kkGlint { - 0% { transform: translateX(-120%); } - 45% { transform: translateX(0%); } - 100% { transform: translateX(120%); } - } - - /* --- Accessibility --------------------------------------------------------- */ - - .kk-btn:focus-visible, - .kk-row input:focus-visible, - .kk-row textarea:focus-visible { - outline: none; - box-shadow: var(--kk-focus); - } - - @media (prefers-reduced-motion: reduce) { - * { animation: none !important; transition: none !important; } - } - - /* --- Print Styles ---------------------------------------------------------- */ - - @media print { - /* Hide UI chrome */ - .kk-note, - .kk-bar, - .kk-hero, - .kk-row, - .kk-grid, - .kk-stack, - .kk-actions, - .kk-note-preview { - display: none !important; - } - - /* Allow print root content (injected proof pages) to flow */ - #print-root { - display: block !important; - } - - body { - background: #fff !important; - } - } - - /* --- Micro-details --------------------------------------------------------- */ - - ::selection { - background: rgba(86,255,227,0.25); - } - - /* Subtle separators inside the hero area */ - .kk-hero .kk-status + .kk-value-row { - border-top: 1px dashed var(--kk-border); - padding-top: 14px; - } - - /* Tiny helper for mono-like hashes inside text fields */ - input[readonly], textarea[readonly] { - font-family: var(--kk-font-mono); - letter-spacing: 0.2px; +/* ───────────────────────────────────────────────────────────────────────────── + Root shell + tokens + ───────────────────────────────────────────────────────────────────────────── */ + +.kk-note { + /* Theme tokens (scoped) */ + --kk-bg: #0b0d10; + --kk-surface: rgba(18, 22, 30, 0.72); + --kk-surface-2: rgba(24, 30, 40, 0.72); + + --kk-text: #eaf1ff; + --kk-dim: rgba(234, 241, 255, 0.74); + --kk-mute: rgba(234, 241, 255, 0.52); + + --kk-border: rgba(255, 255, 255, 0.10); + --kk-border-2: rgba(255, 255, 255, 0.16); + --kk-ring: rgba(86, 255, 227, 0.26); + + --kk-accent: #56ffe3; + --kk-accent2: #9ef7ff; + --kk-gold: #f5d98d; + --kk-emerald: #3de1a7; + --kk-rose: #ff6f9f; + + --kk-radius-sm: 12px; + --kk-radius: 16px; + --kk-radius-lg: 20px; + + --kk-shadow-1: 0 10px 30px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.03); + --kk-shadow-2: 0 18px 54px rgba(0, 0, 0, 0.38), inset 0 1px 0 rgba(255, 255, 255, 0.05); + + --kk-font-ui: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Noto Sans", + "Liberation Sans", "Apple Color Emoji", "Segoe UI Emoji"; + --kk-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + + --kk-focus: 0 0 0 3px var(--kk-ring), 0 0 24px rgba(86, 255, 227, 0.20); + + /* Breath timing (injected from TS) */ + --kk-breath-ms: 5236ms; + + /* Micro-motion helpers */ + --kk-ease: cubic-bezier(0.16, 1, 0.3, 1); + --kk-fast: 120ms; + --kk-med: 220ms; + --kk-slow: 520ms; + + font-family: var(--kk-font-ui); + color: var(--kk-text); + + background: + radial-gradient(900px 700px at 15% -10%, rgba(86, 255, 227, 0.12), transparent 55%), + radial-gradient(900px 700px at 110% 120%, rgba(158, 247, 255, 0.10), transparent 45%), + radial-gradient(700px 300px at 45% 0%, rgba(255, 255, 255, 0.05), transparent 60%), + linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent), + var(--kk-bg); + + border: 1px solid var(--kk-border); + border-radius: var(--kk-radius-lg); + box-shadow: var(--kk-shadow-2); + padding: 14px; + overflow: hidden; + position: relative; + isolation: isolate; + + /* nicer text rendering */ + text-rendering: geometricPrecision; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.kk-note * { box-sizing: border-box; } + +.kk-note::before { + content: ""; + position: absolute; + inset: -2px; + background: + conic-gradient(from 180deg at 50% 10%, rgba(86, 255, 227, 0.10), transparent 25%, transparent 70%, rgba(245, 217, 141, 0.08)), + radial-gradient(900px 280px at 50% 0%, rgba(255, 255, 255, 0.05), transparent 60%); + opacity: 0.65; + pointer-events: none; + mix-blend-mode: overlay; +} + +.kk-note::after { + content: ""; + position: absolute; + inset: 0; + background: + radial-gradient(900px 240px at 30% 10%, rgba(86,255,227,0.06), transparent 60%), + radial-gradient(900px 240px at 70% 0%, rgba(245,217,141,0.04), transparent 60%); + opacity: 0.55; + pointer-events: none; + mix-blend-mode: screen; +} + +/* Selection */ +.kk-note ::selection { background: rgba(86,255,227,0.22); } + +/* Mono helper */ +.kk-mono { font-family: var(--kk-font-mono); } + +/* Utility: subtle separators without adding elements */ +.kk-note hr { + border: 0; + height: 1px; + background: rgba(255,255,255,0.08); +} + +/* ───────────────────────────────────────────────────────────────────────────── + Premium Header (one-row crystalline icon pills) + ───────────────────────────────────────────────────────────────────────────── */ + +.kk-headbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + + padding: 10px; + border-radius: var(--kk-radius-lg); + border: 1px solid var(--kk-border); + + background: + radial-gradient(900px 200px at 40% -30%, rgba(255,255,255,0.06), transparent 60%), + linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02)); + + box-shadow: var(--kk-shadow-1); + backdrop-filter: blur(10px); + + position: sticky; + top: 0; + z-index: 4; +} + +@supports not (backdrop-filter: blur(10px)) { + .kk-headbar { background: rgba(18,22,30,0.80); } +} + +.kk-headbar__left, +.kk-headbar__right { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.kk-headbar__left { + flex: 1; + overflow: hidden; + -webkit-mask-image: linear-gradient(90deg, #000 80%, transparent 100%); + mask-image: linear-gradient(90deg, #000 80%, transparent 100%); +} + +.kk-headbar__right { + flex: 0 0 auto; +} + +/* Pill base (header) */ +.kk-pill { + display: inline-flex; + align-items: center; + gap: 8px; + white-space: nowrap; + + padding: 7px 10px; + border-radius: 999px; + + border: 1px solid rgba(255,255,255,0.12); + background: + linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02)); + box-shadow: + 0 10px 28px rgba(0,0,0,0.18), + inset 0 1px 0 rgba(255,255,255,0.04); + + color: var(--kk-dim); + font-size: 12px; + transition: transform var(--kk-fast) var(--kk-ease), border-color var(--kk-fast) var(--kk-ease), background var(--kk-fast) var(--kk-ease); +} + +.kk-pill:hover { transform: translateY(-1px); border-color: rgba(255,255,255,0.20); } + +/* Brand pill */ +.kk-pill--brand { + padding: 7px 9px; + border-color: rgba(86,255,227,0.18); + background: + radial-gradient(120px 50px at 50% -40%, rgba(86,255,227,0.20), transparent 60%), + linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02)); +} + +.kk-brandMark { + width: 18px; + height: 18px; + display: inline-grid; + place-items: center; + font-weight: 950; + letter-spacing: -0.02em; + color: transparent; + background: linear-gradient(180deg, var(--kk-accent), var(--kk-accent2)); + -webkit-background-clip: text; + background-clip: text; + filter: drop-shadow(0 6px 18px rgba(86,255,227,0.22)); +} + +/* State pill */ +.kk-pill--state { + padding: 7px 9px; + border-color: rgba(255,255,255,0.14); + color: rgba(234,241,255,0.82); +} + +.kk-pill--state.is-live { + border-color: rgba(61,225,167,0.28); + background: + radial-gradient(120px 50px at 50% -40%, rgba(61,225,167,0.18), transparent 65%), + linear-gradient(180deg, rgba(255,255,255,0.05), rgba(255,255,255,0.02)); + animation: kk-head-glow var(--kk-breath-ms) ease-in-out infinite; +} + +.kk-pill--state.is-locked { + border-color: rgba(245,217,141,0.28); + background: + radial-gradient(120px 50px at 50% -40%, rgba(245,217,141,0.16), transparent 65%), + linear-gradient(180deg, rgba(255,255,255,0.05), rgba(255,255,255,0.02)); +} + +@keyframes kk-head-glow { + 0% { box-shadow: 0 10px 28px rgba(0,0,0,0.18), inset 0 1px 0 rgba(255,255,255,0.04), 0 0 0 0 rgba(61,225,167,0.20); } + 55% { box-shadow: 0 10px 28px rgba(0,0,0,0.18), inset 0 1px 0 rgba(255,255,255,0.04), 0 0 0 12px rgba(61,225,167,0.00); } + 100% { box-shadow: 0 10px 28px rgba(0,0,0,0.18), inset 0 1px 0 rgba(255,255,255,0.04), 0 0 0 0 rgba(61,225,167,0.00); } +} + +/* Pulse / value pills */ +.kk-pill--pulse, +.kk-pill--value, +.kk-pill--usd, +.kk-pill--progress { + font-variant-numeric: tabular-nums; +} + +.kk-pill--pulse { max-width: 160px; overflow: hidden; } +.kk-pill--value { max-width: 180px; overflow: hidden; } +.kk-pill--usd { max-width: 140px; overflow: hidden; } +.kk-pill--progress { padding: 7px 9px; } + +.kk-pillPhi { + font-weight: 900; + color: rgba(158,247,255,0.92); +} + +/* Mode pill (two icon buttons inside) */ +.kk-pill--mode { + padding: 6px; + gap: 6px; +} + +.kk-iconBtn { + appearance: none; + border: 1px solid rgba(255,255,255,0.12); + background: rgba(255,255,255,0.03); + color: rgba(234,241,255,0.78); + width: 34px; + height: 30px; + border-radius: 999px; + display: inline-grid; + place-items: center; + cursor: pointer; + transition: transform var(--kk-fast) var(--kk-ease), border-color var(--kk-fast) var(--kk-ease), background var(--kk-fast) var(--kk-ease), box-shadow var(--kk-fast) var(--kk-ease); +} + +.kk-iconBtn:hover { + border-color: rgba(255,255,255,0.20); + background: rgba(255,255,255,0.05); + transform: translateY(-1px); +} + +.kk-iconBtn:active { transform: translateY(0); } + +.kk-iconBtn.is-on { + border-color: rgba(86,255,227,0.30); + background: linear-gradient(180deg, rgba(86,255,227,0.18), rgba(86,255,227,0.08)); + color: rgba(234,241,255,0.95); + box-shadow: 0 10px 22px rgba(86,255,227,0.14); +} + +.kk-iconBtn:focus-visible { + outline: none; + box-shadow: var(--kk-focus); +} + +/* Shield pill toggle */ +.kk-pill--shield { + width: 38px; + height: 34px; + padding: 0; + justify-content: center; + cursor: pointer; + color: rgba(234,241,255,0.80); + transition: transform var(--kk-fast) var(--kk-ease), border-color var(--kk-fast) var(--kk-ease), background var(--kk-fast) var(--kk-ease), box-shadow var(--kk-fast) var(--kk-ease); +} + +.kk-pill--shield:hover { transform: translateY(-1px); border-color: rgba(255,255,255,0.22); } +.kk-pill--shield:active { transform: translateY(0); } +.kk-pill--shield.is-on { + border-color: rgba(245,217,141,0.26); + background: linear-gradient(180deg, rgba(245,217,141,0.12), rgba(245,217,141,0.05)); + box-shadow: 0 12px 26px rgba(245,217,141,0.08); +} + +/* Mobile tightening */ +@media (max-width: 560px) { + .kk-note { padding: 12px; } + .kk-headbar { padding: 8px; gap: 8px; } + .kk-pill--usd { display: none; } +} + +/* ───────────────────────────────────────────────────────────────────────────── + Hero / Valuation + ───────────────────────────────────────────────────────────────────────────── */ + +.kk-hero2 { + margin-top: 12px; + padding: 14px; + border-radius: var(--kk-radius-lg); + border: 1px solid var(--kk-border); + background: rgba(10, 12, 16, 0.40); + box-shadow: var(--kk-shadow-2); + position: relative; +} + +.kk-hero2.is-live { outline: 1px solid rgba(61,225,167,0.22); } +.kk-hero2.is-locked { outline: 1px solid rgba(245,217,141,0.22); } + +.kk-hero2__row { + display: grid; + grid-template-columns: 1fr minmax(280px, 38%); + gap: 14px; + align-items: center; +} + +@media (max-width: 980px) { + .kk-hero2__row { grid-template-columns: 1fr; } +} + +.kk-hero2__status { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-bottom: 10px; +} + +.kk-chip2 { + font-size: 12px; + color: var(--kk-text); + border: 1px solid var(--kk-border); + padding: 6px 10px; + border-radius: 999px; + background: rgba(255,255,255,0.04); + display: inline-flex; + align-items: center; + gap: 6px; + letter-spacing: 0.2px; +} + +.kk-chip2--pulse { font-variant-numeric: tabular-nums; } + +.chip-live { + border-color: rgba(61,225,167,0.35); + background: linear-gradient(180deg, rgba(61,225,167,0.22), rgba(61,225,167,0.12)); + animation: kk-breath-ring var(--kk-breath-ms) ease-in-out infinite; +} + +.chip-locked { + border-color: rgba(245,217,141,0.40); + background: linear-gradient(180deg, rgba(245,217,141,0.22), rgba(245,217,141,0.12)); +} + +@keyframes kk-breath-ring { + 0% { box-shadow: 0 0 0 0 rgba(61,225,167,0.28); } + 55% { box-shadow: 0 0 0 12px rgba(61,225,167,0.00); } + 100% { box-shadow: 0 0 0 0 rgba(61,225,167,0.00); } +} + +.kk-hero2__big { + border: 1px solid var(--kk-border); + border-radius: var(--kk-radius); + padding: 12px 14px; + background: linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02)); + box-shadow: var(--kk-shadow-1); + position: relative; + overflow: hidden; +} + +.kk-hero2__big::after { + content: ""; + position: absolute; + inset: -40% -10% auto -10%; + height: 90%; + background: radial-gradient(closest-side, rgba(255,255,255,0.09), transparent 70%); + transform: rotate(-12deg); + opacity: 0.55; + pointer-events: none; +} + +.kk-big__label { + font-size: 12px; + letter-spacing: 3px; + text-transform: uppercase; + color: var(--kk-mute); + margin-bottom: 6px; +} + +.kk-big__num { + display: flex; + align-items: flex-end; + gap: 10px; + line-height: 1; +} + +.kk-big__phi { + font-weight: 900; + font-size: clamp(26px, 5vw, 44px); + background: linear-gradient(180deg, var(--kk-accent), var(--kk-accent2)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + filter: drop-shadow(0 6px 18px rgba(86,255,227,0.22)); +} + +.kk-big__int { + font-variant-numeric: tabular-nums; + font-weight: 950; + font-size: clamp(38px, 8vw, 64px); + letter-spacing: -0.02em; +} + +.kk-big__frac { + font-variant-numeric: tabular-nums; + font-size: clamp(16px, 3.2vw, 26px); + color: var(--kk-dim); + padding-bottom: 4px; +} + +.kk-big__usd { + margin-top: 6px; + font-size: 13px; + color: var(--kk-mute); +} + +.kk-hero2__actions { + display: grid; + gap: 10px; + align-content: start; +} + +.kk-hero2__cta { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +/* ───────────────────────────────────────────────────────────────────────────── + Buttons (kept compatible) + ───────────────────────────────────────────────────────────────────────────── */ + +.kk-btn { + appearance: none; + border: 1px solid var(--kk-border); + background: rgba(255,255,255,0.04); + color: var(--kk-text); + border-radius: 12px; + padding: 10px 14px; + font-weight: 850; + letter-spacing: 0.15px; + cursor: pointer; + box-shadow: var(--kk-shadow-1); + transition: transform var(--kk-fast) var(--kk-ease), box-shadow var(--kk-fast) var(--kk-ease), border-color var(--kk-fast) var(--kk-ease), background var(--kk-fast) var(--kk-ease), filter var(--kk-fast) var(--kk-ease); +} + +.kk-btn:hover { border-color: rgba(255,255,255,0.20); transform: translateY(-1px); filter: brightness(1.03); } +.kk-btn:active { transform: translateY(0); box-shadow: 0 6px 14px rgba(0,0,0,0.16) inset; } +.kk-btn[disabled] { opacity: 0.58; cursor: not-allowed; transform: none; filter: none; } + +.kk-btn-primary { + border-color: rgba(86,255,227,0.52); + color: #001f19; + background: linear-gradient(180deg, rgba(147,255,233,0.98), rgba(73,255,215,0.88)); + box-shadow: 0 18px 44px rgba(86,255,227,0.22), inset 0 1px 0 rgba(255,255,255,0.40); +} + +.kk-btn-primary:hover { + box-shadow: 0 22px 54px rgba(86,255,227,0.28), inset 0 1px 0 rgba(255,255,255,0.52); +} + +.kk-btn-ghost { background: transparent; border-color: rgba(255,255,255,0.16); } +.kk-btn-xl { padding: 14px 18px; font-size: 16px; border-radius: 14px; } + +.kk-btn:focus-visible { outline: none; box-shadow: var(--kk-focus); } + +/* Icon-only button sizing (used for top step controls) */ +.kk-iconOnly { + width: 44px; + height: 42px; + display: inline-grid; + place-items: center; + padding: 0; +} + +/* ───────────────────────────────────────────────────────────────────────────── + Lock card + ───────────────────────────────────────────────────────────────────────────── */ + +.kk-lockcard { + border: 1px dashed rgba(245,217,141,0.55); + background: linear-gradient(180deg, rgba(245,217,141,0.10), rgba(245,217,141,0.05)); + border-radius: var(--kk-radius); + padding: 10px 12px; +} + +.kk-lockcard__t { + font-weight: 950; + color: var(--kk-gold); +} + +.kk-lockcard__s { + margin-top: 4px; + font-size: 12px; + color: var(--kk-dim); + word-break: break-word; +} + +/* ───────────────────────────────────────────────────────────────────────────── + Dual bar: Step answer (left) + Send Amount (right) + ───────────────────────────────────────────────────────────────────────────── */ + +.kk-dualbar { + margin-top: 12px; + display: grid; + gap: 12px; + grid-template-columns: 1fr minmax(340px, 460px); + align-items: start; +} + +@media (max-width: 980px) { + .kk-dualbar { grid-template-columns: 1fr; } +} + +/* Step card */ +.kk-qaCard { + border: 1px solid rgba(255,255,255,0.12); + border-radius: var(--kk-radius); + background: + radial-gradient(600px 180px at 30% -30%, rgba(86,255,227,0.10), transparent 60%), + linear-gradient(180deg, rgba(255,255,255,0.05), rgba(255,255,255,0.02)); + box-shadow: var(--kk-shadow-1); + padding: 12px; +} + +.kk-qaHead { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.kk-qaMeta { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--kk-mute); + font-size: 12px; +} + +.kk-qaTag { + font-weight: 950; + color: rgba(234,241,255,0.92); + font-size: 12px; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid rgba(255,255,255,0.12); + background: rgba(0,0,0,0.18); +} + +.kk-qaPrompt { + margin-top: 8px; + font-weight: 950; + line-height: 1.2; +} + +.kk-qaRow { + margin-top: 10px; + display: grid; + grid-template-columns: 1fr auto; + gap: 10px; + align-items: center; +} + +@media (max-width: 720px) { + .kk-qaRow { grid-template-columns: 1fr; } +} + +.kk-qaInput { + width: 100%; + border-radius: 14px; + border: 1px solid var(--kk-border); + background: rgba(24, 30, 40, 0.72); + color: var(--kk-text); + padding: 12px 14px; + outline: none; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.03); + transition: box-shadow var(--kk-fast) var(--kk-ease), border-color var(--kk-fast) var(--kk-ease), transform var(--kk-fast) var(--kk-ease); +} + +.kk-qaInput::placeholder { color: rgba(234,241,255,0.42); } + +.kk-qaInput:focus { + border-color: rgba(86,255,227,0.60); + box-shadow: var(--kk-focus); + transform: translateY(-1px); +} + +.kk-qaInput:disabled { + opacity: 0.70; + cursor: not-allowed; +} + +.kk-qaBtns { + display: inline-flex; + gap: 10px; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; +} + +/* Suggestions */ +.kk-suggest { + margin-top: 10px; + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.kk-suggest__chip { + border: 1px solid rgba(255,255,255,0.14); + background: rgba(255,255,255,0.03); + color: var(--kk-dim); + border-radius: 999px; + padding: 6px 10px; + font-weight: 850; + font-size: 12px; + cursor: pointer; + transition: transform var(--kk-fast) var(--kk-ease), border-color var(--kk-fast) var(--kk-ease), background var(--kk-fast) var(--kk-ease); +} + +.kk-suggest__chip:hover { border-color: rgba(255,255,255,0.24); transform: translateY(-1px); } +.kk-suggest__chip:active { transform: translateY(0); } + +.kk-suggest__chip:focus-visible { outline: none; box-shadow: var(--kk-focus); } + +/* ───────────────────────────────────────────────────────────────────────────── + Send Amount bar + Unit toggle + ───────────────────────────────────────────────────────────────────────────── */ + +.kk-sendbar { + border: 1px solid var(--kk-border); + border-radius: var(--kk-radius); + background: rgba(255,255,255,0.03); + padding: 12px; + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + position: relative; + overflow: hidden; +} + +.kk-sendbar::before { + content: ""; + position: absolute; + inset: -1px; + background: + radial-gradient(480px 140px at 10% 0%, rgba(86,255,227,0.08), transparent 60%), + radial-gradient(480px 140px at 95% 0%, rgba(245,217,141,0.06), transparent 60%); + opacity: 0.70; + pointer-events: none; +} + +@media (max-width: 720px) { + .kk-sendbar { flex-direction: column; align-items: stretch; } +} + +.kk-sendbar__label { + font-weight: 950; + letter-spacing: 0.3px; +} + +.kk-sendbar__sub { + font-size: 12px; + color: var(--kk-mute); + margin-top: 2px; +} + +.kk-sendbar__right { + display: flex; + gap: 12px; + align-items: center; + position: relative; + z-index: 1; /* above sendbar::before */ +} + +@media (max-width: 720px) { + .kk-sendbar__right { justify-content: space-between; } +} + +/* Unit segmented control */ +.kk-sendbar__unit { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px; + border-radius: 999px; + border: 1px solid rgba(255,255,255,0.14); + background: + linear-gradient(180deg, rgba(255,255,255,0.05), rgba(255,255,255,0.02)); + box-shadow: 0 10px 22px rgba(0,0,0,0.16), inset 0 1px 0 rgba(255,255,255,0.04); + backdrop-filter: blur(8px); +} + +.kk-sendbar__unitBtn { + appearance: none; + border: 1px solid rgba(255,255,255,0.12); + background: rgba(255,255,255,0.03); + color: rgba(234,241,255,0.86); + width: 38px; + height: 30px; + border-radius: 999px; + display: inline-grid; + place-items: center; + cursor: pointer; + font-weight: 950; + letter-spacing: -0.01em; + transition: transform var(--kk-fast) var(--kk-ease), border-color var(--kk-fast) var(--kk-ease), background var(--kk-fast) var(--kk-ease), box-shadow var(--kk-fast) var(--kk-ease), filter var(--kk-fast) var(--kk-ease); +} + +.kk-sendbar__unitBtn:hover { + border-color: rgba(255,255,255,0.22); + background: rgba(255,255,255,0.06); + transform: translateY(-1px); +} + +.kk-sendbar__unitBtn:active { transform: translateY(0); } + +.kk-sendbar__unitBtn.is-on { + border-color: rgba(86,255,227,0.34); + background: linear-gradient(180deg, rgba(86,255,227,0.22), rgba(86,255,227,0.10)); + color: rgba(234,241,255,0.98); + box-shadow: 0 14px 28px rgba(86,255,227,0.12); +} + +.kk-sendbar__unitBtn[disabled] { + opacity: 0.55; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.kk-sendbar__unitBtn:focus-visible { outline: none; box-shadow: var(--kk-focus); } + +.kk-sendbar__inputWrap { + display: inline-flex; + align-items: center; + gap: 8px; + border: 1px solid var(--kk-border); + border-radius: 12px; + padding: 8px 10px; + background: rgba(24, 30, 40, 0.65); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.03); + min-height: 42px; +} + +.kk-sendbar__prefix { + font-weight: 950; + color: rgba(158,247,255,0.92); +} + +.kk-sendbar__input { + width: 170px; + border: 0; + outline: none; + background: transparent; + color: var(--kk-text); + font-weight: 950; + font-size: 14px; + font-variant-numeric: tabular-nums; +} + +.kk-sendbar__input:disabled { opacity: 0.70; cursor: not-allowed; } +.kk-sendbar__input.is-error { color: #ffd3dc; } + +.kk-sendbar__inputWrap:has(.kk-sendbar__input:focus) { + border-color: rgba(86,255,227,0.60); + box-shadow: var(--kk-focus); +} + +.kk-sendbar__meta { + display: grid; + gap: 2px; + justify-items: end; + min-width: 140px; +} + +.kk-sendbar__usd { + font-weight: 950; + font-variant-numeric: tabular-nums; +} + +.kk-sendbar__hint { + font-size: 12px; + color: var(--kk-mute); +} + +/* Error hint emphasis */ +.kk-sendbar__input.is-error ~ .kk-sendbar__meta, +.kk-sendbar__inputWrap:has(.kk-sendbar__input.is-error) + .kk-sendbar__meta { + filter: saturate(1.05); +} + +/* ───────────────────────────────────────────────────────────────────────────── + Chat panel (history) + ───────────────────────────────────────────────────────────────────────────── */ + +.kk-chatpanel { + margin-top: 12px; + border: 1px solid var(--kk-border); + border-radius: var(--kk-radius-lg); + background: rgba(10, 12, 16, 0.35); + box-shadow: var(--kk-shadow-2); + overflow: hidden; +} + +.kk-chatpanel__head { + padding: 12px 12px; + border-bottom: 1px solid rgba(255,255,255,0.08); + background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.01)); +} + +.kk-chatpanel__h { + font-weight: 950; + display: flex; + gap: 10px; + align-items: center; +} + +.kk-chatpanel__badge { + font-size: 11px; + padding: 4px 8px; + border-radius: 999px; + border: 1px solid var(--kk-border); + color: var(--kk-dim); + background: rgba(255,255,255,0.03); + text-transform: uppercase; + letter-spacing: 0.18em; +} + +.kk-chatpanel__hint { + margin-top: 4px; + font-size: 12px; + color: var(--kk-mute); +} + +.kk-chatpanel__body { + padding: 14px 12px; + max-height: min(70vh, 720px); + overflow: auto; + scroll-behavior: smooth; + overscroll-behavior: contain; +} + +/* Crisp scrollbars (webkit) */ +.kk-chatpanel__body::-webkit-scrollbar { width: 10px; height: 10px; } +.kk-chatpanel__body::-webkit-scrollbar-thumb { + background: rgba(255,255,255,0.10); + border: 2px solid rgba(0,0,0,0.20); + border-radius: 999px; +} +.kk-chatpanel__body::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.16); } +.kk-chatpanel__body::-webkit-scrollbar-corner { background: transparent; } + +.kk-bubbleRow { + display: flex; + margin: 10px 0; +} + +.kk-bubbleRow.is-sys { justify-content: flex-start; } +.kk-bubbleRow.is-you { justify-content: flex-end; } + +.kk-bubble { + max-width: min(720px, 92%); + border-radius: 16px; + padding: 10px 12px; + border: 1px solid rgba(255,255,255,0.10); + box-shadow: 0 12px 28px rgba(0,0,0,0.18); + position: relative; + transform: translateZ(0); +} + +.kk-bubble--sys { + background: linear-gradient(180deg, rgba(255,255,255,0.045), rgba(255,255,255,0.02)); + color: var(--kk-text); +} + +.kk-bubble--you { + background: linear-gradient(180deg, rgba(86,255,227,0.18), rgba(86,255,227,0.10)); + border-color: rgba(86,255,227,0.22); + color: var(--kk-text); +} + +.kk-bubble__text { + white-space: pre-wrap; + word-break: break-word; + line-height: 1.35; +} + +.kk-bubble__jump { + position: absolute; + top: 8px; + right: 10px; + border: 1px solid rgba(255,255,255,0.16); + background: rgba(0,0,0,0.18); + color: rgba(234,241,255,0.86); + border-radius: 999px; + font-size: 11px; + padding: 4px 8px; + cursor: pointer; + transition: transform var(--kk-fast) var(--kk-ease), border-color var(--kk-fast) var(--kk-ease), background var(--kk-fast) var(--kk-ease); +} + +.kk-bubble__jump:hover { border-color: rgba(255,255,255,0.26); transform: translateY(-1px); } +.kk-bubble__jump:active { transform: translateY(0); } +.kk-bubble__jump:focus-visible { outline: none; box-shadow: var(--kk-focus); } + +/* ───────────────────────────────────────────────────────────────────────────── + Preview card + ───────────────────────────────────────────────────────────────────────────── */ + +.kk-previewCard { + margin: 14px 0 10px; + border-radius: var(--kk-radius-lg); + border: 1px solid var(--kk-border); + background: + repeating-linear-gradient( + 45deg, + rgba(255,255,255,0.012), + rgba(255,255,255,0.012) 8px, + rgba(0,0,0,0.02) 8px, + rgba(0,0,0,0.02) 16px + ), + rgba(18, 22, 30, 0.55); + overflow: hidden; + box-shadow: var(--kk-shadow-2); + position: relative; +} + +.kk-previewCard::after { + content: ""; + position: absolute; + inset: -2px; + background: radial-gradient(700px 220px at 40% 0%, rgba(255,255,255,0.04), transparent 60%); + opacity: 0.6; + pointer-events: none; +} + +.kk-previewCard__head { + padding: 10px 12px; + border-bottom: 1px solid rgba(255,255,255,0.08); + background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.01)); + position: relative; + z-index: 1; +} + +.kk-previewCard__t { font-weight: 950; } +.kk-previewCard__s { margin-top: 2px; font-size: 12px; color: var(--kk-mute); } + +.kk-previewCard--flat { margin-top: 14px; } + +.kk-note-preview { width: 100%; max-width: 980px; margin: 0 auto; padding: 10px; position: relative; z-index: 1; } +.kk-note-preview svg { display: block; width: 100% !important; height: auto !important; } + +/* ───────────────────────────────────────────────────────────────────────────── + Classic form compatibility + ───────────────────────────────────────────────────────────────────────────── */ + +.kk-formpanel { margin-top: 12px; } + +.kk-row { + display: grid; + grid-template-columns: 160px 1fr; + gap: 12px; + align-items: center; + margin-top: 14px; +} + +@media (max-width: 700px) { + .kk-row { grid-template-columns: 1fr; } +} + +.kk-grid { + display: grid; + gap: 14px; + grid-template-columns: 1fr 1fr; +} + +@media (max-width: 900px) { + .kk-grid { grid-template-columns: 1fr; } +} + +.kk-stack { display: grid; gap: 12px; } + +.kk-row > label { + color: var(--kk-mute); + font-size: 13px; + letter-spacing: 0.2px; +} + +.kk-row input, +.kk-row textarea { + width: 100%; + color: var(--kk-text); + background: rgba(24, 30, 40, 0.72); + border: 1px solid var(--kk-border); + border-radius: 12px; + padding: 10px 12px; + outline: none; + transition: box-shadow var(--kk-fast) var(--kk-ease), border-color var(--kk-fast) var(--kk-ease), transform var(--kk-fast) var(--kk-ease); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.03); +} + +.kk-row textarea { min-height: 120px; resize: vertical; } + +.kk-row input:focus, +.kk-row textarea:focus { + border-color: rgba(86,255,227,0.60); + box-shadow: var(--kk-focus); + transform: translateY(-1px); +} + +.kk-row input:disabled, +.kk-row textarea:disabled { + opacity: 0.75; + cursor: not-allowed; +} + +.kk-out { font-family: var(--kk-font-mono); } + +/* ───────────────────────────────────────────────────────────────────────────── + Reduced motion + ───────────────────────────────────────────────────────────────────────────── */ + +@media (prefers-reduced-motion: reduce) { + .kk-note * { animation: none !important; transition: none !important; scroll-behavior: auto !important; } +} + +/* ───────────────────────────────────────────────────────────────────────────── + Print rules + ───────────────────────────────────────────────────────────────────────────── */ + +@media print { + .kk-note, + .kk-headbar, + .kk-hero2, + .kk-chatpanel, + .kk-formpanel { + display: none !important; } - \ No newline at end of file + + #print-root { display: block !important; } + body { background: #fff !important; } +} diff --git a/src/components/ExhaleNote.tsx b/src/components/ExhaleNote.tsx index 4cb2b5dbf..14267db4c 100644 --- a/src/components/ExhaleNote.tsx +++ b/src/components/ExhaleNote.tsx @@ -1,6 +1,22 @@ // src/components/ExhaleNote.tsx /* eslint-disable @typescript-eslint/consistent-type-assertions */ -import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; +/** + * ExhaleNote — Exhale Note Composer + * v26.3 — GUIDED STEP COMPOSER + SEND AMOUNT UNIT TOGGLE (Φ ⇄ USD) + * - Same valuation/lock/print/save logic + * - Guided mode is truly step-by-step: + * - Answer box is TOP (next to Send Amount) + * - Chat shows past Q/A + current question only (no future questions) + * - No bottom composer + * - Send Amount now supports unit toggle: + * - Enter Φ or USD (toggle) + * - Always computes the exact paired value + * - Uses locked USD/Φ rate after Render (valuation locked) + * - Premium Atlantean-glass header: one-row crystalline icon pills (mobile-first) + * - Fix: `disabled` always receives boolean (no `locked` object leaks) + * - Terminology: UI says “note” (not “bill”) + */ +import React, { useCallback, useEffect, useId, useMemo, useRef, useState, type CSSProperties } from "react"; import type { ValueSeal } from "../utils/valuation"; import { computeIntrinsicUnsigned } from "../utils/valuation"; @@ -13,9 +29,12 @@ import { renderPreview } from "./exhale-note/dom"; import { buildBanknoteSVG } from "./exhale-note/banknoteSvg"; import buildProofPagesHTML from "./exhale-note/proofPages"; import { printWithTempTitle, renderIntoPrintRoot } from "./exhale-note/printer"; -import { fPhi, fUsd, fTiny } from "./exhale-note/format"; +import { fUsd, fTiny } from "./exhale-note/format"; import { fetchFromVerifierBridge } from "./exhale-note/bridge"; import { svgStringToPngBlob, triggerDownload } from "./exhale-note/svgToPng"; +import { insertPngTextChunks } from "../utils/pngChunks"; +import { buildVerifierUrl } from "./KaiVoh/verifierProof"; +import { derivePhiKeyFromSig } from "./VerifierStamper/sigilUtils"; import type { NoteProps, @@ -23,6 +42,8 @@ import type { IntrinsicUnsigned, MaybeUnsignedSeal, ExhaleNoteRenderPayload, + NoteSendPayload, + NoteSendResult, } from "./exhale-note/types"; /* External stylesheet */ @@ -58,7 +79,6 @@ function makeFileTitle(kaiSig: string, pulse: string, stamp: string): string { .replace(/-+/g, "-") .slice(0, 180); - // ✅ “KAI + pulse” first (matches how you want it named) return `KAI-${safe(pulse)}-SIGIL-${safe(serialCore)}—VAL-${safe(stamp)}`; } @@ -68,6 +88,45 @@ function formatPhiParts(val: number): { int: string; frac: string } { return { int: i, frac: f ? `.${f}` : "" }; } +function parsePhiInput(raw: string): number | null { + const cleaned = raw.replace(/,/g, ".").trim(); + if (!cleaned) return null; + const num = Number(cleaned); + if (!Number.isFinite(num) || num <= 0) return null; + return Number(num.toFixed(6)); +} + +function parseUsdInput(raw: string): number | null { + let cleaned = raw.trim().replace(/\$/g, "").replace(/\s+/g, ""); + if (!cleaned) return null; + + // If user typed "10,5" (comma as decimal), and there's no dot, treat comma as decimal. + if (cleaned.includes(",") && !cleaned.includes(".")) cleaned = cleaned.replace(/,/g, "."); + else cleaned = cleaned.replace(/,/g, ""); + + const num = Number(cleaned); + if (!Number.isFinite(num) || num <= 0) return null; + return Number(num.toFixed(6)); +} + +function formatUsdInput(val: number): string { + if (!Number.isFinite(val) || val <= 0) return ""; + // Input-friendly numeric string (no $), stable for toggles. + return val.toFixed(2); +} + +function generateNonce(): string { + if (typeof crypto === "undefined" || !("getRandomValues" in crypto)) { + return `${Date.now()}${Math.random().toString(16).slice(2)}`; + } + return crypto.getRandomValues(new Uint32Array(3)).join(""); +} + +function toScaledPhi18(amountPhi: number): string { + const safe = Number.isFinite(amountPhi) ? amountPhi : 0; + return safe.toFixed(18).replace(".", ""); +} + /** Wait two animation frames to guarantee paint before print */ function afterTwoFrames(): Promise { return new Promise((resolve) => { @@ -110,6 +169,102 @@ function resolveVerifyUrl(raw: string | undefined, fallbackAbs: string): string return candidate; } +type NoteProofBundleFields = { + verifierUrl?: string; + bundleHash?: string; + receiptHash?: string; + verifiedAtPulse?: number; + capsuleHash?: string; + svgHash?: string; + proofCapsule?: { + pulse?: number; + kaiSignature?: string; + }; +}; + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readOptionalString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +function readOptionalNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value); + return undefined; +} + +function parseProofBundleJson(raw: string | undefined): NoteProofBundleFields { + if (!raw) return {}; + try { + const parsed = JSON.parse(raw) as unknown; + if (!isPlainRecord(parsed)) return {}; + let proofCapsule: NoteProofBundleFields["proofCapsule"]; + const capsuleRaw = parsed.proofCapsule; + if (isPlainRecord(capsuleRaw)) { + const pulse = readOptionalNumber(capsuleRaw.pulse); + const kaiSignature = readOptionalString(capsuleRaw.kaiSignature); + if (pulse != null || kaiSignature) proofCapsule = { pulse: pulse ?? undefined, kaiSignature }; + } + return { + verifierUrl: readOptionalString(parsed.verifierUrl), + bundleHash: readOptionalString(parsed.bundleHash), + receiptHash: readOptionalString(parsed.receiptHash), + verifiedAtPulse: readOptionalNumber(parsed.verifiedAtPulse), + capsuleHash: readOptionalString(parsed.capsuleHash), + svgHash: readOptionalString(parsed.svgHash), + proofCapsule, + }; + } catch { + return {}; + } +} + +function buildQrPayload( + input: { + verifyUrl?: string; + proofBundleJson?: string; + kaiSignature?: string; + pulse?: number; + verifiedAtPulse?: number; + }, + fallbackAbs: string +): string { + return resolveNoteVerifyUrl(input, fallbackAbs); +} + +function resolveNoteVerifyUrl( + input: { + verifyUrl?: string; + proofBundleJson?: string; + kaiSignature?: string; + pulse?: number; + verifiedAtPulse?: number; + }, + fallbackAbs: string +): string { + const parsed = parseProofBundleJson(input.proofBundleJson); + const preferred = parsed.verifierUrl ?? input.verifyUrl; + const resolved = resolveVerifyUrl(preferred, fallbackAbs); + const hasSlug = /\/verify\/[^/?#]+/i.test(resolved); + if (hasSlug) return resolved; + + const pulse = parsed.proofCapsule?.pulse ?? input.pulse; + const sig = parsed.proofCapsule?.kaiSignature ?? input.kaiSignature; + const verifiedAtPulse = parsed.verifiedAtPulse ?? input.verifiedAtPulse; + if (pulse != null && sig) { + const base = /\/verify\/?$/i.test(resolved) ? resolved : undefined; + const built = buildVerifierUrl(pulse, sig, base, verifiedAtPulse); + return resolveVerifyUrl(built, fallbackAbs); + } + + return resolved; +} + /** Inject preview CSS once so the SVG scales on mobile (kept defensive even if ExhaleNote.css exists). */ function ensurePreviewStylesInjected(): void { if (typeof document === "undefined") return; @@ -120,12 +275,6 @@ function ensurePreviewStylesInjected(): void { style.textContent = ` .kk-note-preview { width: 100%; max-width: 980px; margin: 0 auto; } .kk-note-preview svg { display:block; width:100% !important; height:auto !important; } - .kk-hero .kk-value-row { display:flex; gap:16px; align-items:flex-start; } - @media (max-width: 860px) { - .kk-hero .kk-value-row { flex-direction:column; } - .kk-cta { width:100%; } - .kk-cta .kk-cta-actions { display:flex; gap:8px; flex-wrap:wrap; } - } `; document.head.appendChild(style); } @@ -339,11 +488,7 @@ function safeJsonStringify(v: unknown): string { function buildMinimalForStamp(u: IntrinsicUnsigned): MinimalValuationStamp { type HeadRefLike = { - headRef?: { - headHash?: string | null; - transfersWindowRoot?: string | null; - cumulativeTransfers?: number; - }; + headRef?: { headHash?: string | null; transfersWindowRoot?: string | null; cumulativeTransfers?: number }; policyId?: string | null | undefined; inputs?: unknown; }; @@ -375,13 +520,99 @@ function msUntilNextPulseBoundaryLocal(pulseNowInt: number): number { const nowMs = BigInt(Date.now()); const delta = nextPulseMs - nowMs; if (delta <= 0n) return 0; - // delta is always ~0..6000ms here, safe to Number() return Number(delta); } catch { return Math.max(0, Math.floor(KAI_PULSE_MS)); } } +/* ----------------------- + Tiny inline icons (no deps) + ----------------------- */ + +type IconProps = { title?: string; className?: string }; + +function IconSpark({ title, className }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +function IconLock({ title, className }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +function IconWave({ title, className }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +function IconChat({ title, className }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +function IconList({ title, className }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +function IconShield({ title, className }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +function IconBack({ title, className }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +function IconSend({ title, className }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + +function IconSkip({ title, className }: IconProps) { + return ( + + {title ? {title} : null} + + + ); +} + /* ----------------------- Component ----------------------- */ @@ -392,6 +623,9 @@ const ExhaleNote: React.FC = ({ policy = DEFAULT_ISSUANCE_POLICY, getNowPulse, onRender, + availablePhi, + originCanonical, + onSendNote, initial, className, }) => { @@ -408,11 +642,7 @@ const ExhaleNote: React.FC = ({ const readNowPulseInt = useCallback((): number => { const local = momentFromUTC(BigInt(Date.now())).pulse; const ext = getNowPulse?.(); - const extOk = - typeof ext === "number" && - Number.isFinite(ext) && - Math.abs(Math.trunc(ext) - Math.trunc(local)) <= 2; - + const extOk = typeof ext === "number" && Number.isFinite(ext) && Math.abs(Math.trunc(ext) - Math.trunc(local)) <= 2; return Math.trunc(extOk ? (ext as number) : local); }, [getNowPulse]); @@ -500,6 +730,12 @@ const ExhaleNote: React.FC = ({ zk: undefined, sigilSvg: "", verifyUrl: defaultVerifyUrl, + proofBundleJson: "", + bundleHash: "", + receiptHash: "", + verifiedAtPulse: undefined, + capsuleHash: "", + svgHash: "", ...(initial ?? {}), }; @@ -511,9 +747,28 @@ const ExhaleNote: React.FC = ({ /* Lock state */ const [locked, setLocked] = useState(null); + const isLocked = locked !== null; + const lockedRef = useRef(false); const [isRendering, setIsRendering] = useState(false); + /** + * Send Amount: + * - Unit toggle Φ ⇄ USD (only editable post-lock) + * - Store both raw inputs; one is active based on unit. + */ + type SendUnit = "phi" | "usd"; + const [sendUnit, setSendUnit] = useState("phi"); + const [sendPhiInput, setSendPhiInput] = useState(""); + const [sendUsdInput, setSendUsdInput] = useState(""); + + const [sendNonce, setSendNonce] = useState(""); + const sendNonceRef = useRef(""); + + const sendCommittedRef = useRef(false); + const [noteSendResult, setNoteSendResult] = useState(null); + const lastSendPhiCanonRef = useRef(""); + const u = (k: keyof BanknoteInputs) => (v: string): void => @@ -527,10 +782,10 @@ const ExhaleNote: React.FC = ({ return unsigned; }, [meta, nowFloor]); - const liveAlgString = useMemo( - () => `${liveUnsigned.algorithm} • ${liveUnsigned.policyChecksum}`, - [liveUnsigned.algorithm, liveUnsigned.policyChecksum] - ); + const liveAlgString = useMemo(() => `${liveUnsigned.algorithm} • ${liveUnsigned.policyChecksum}`, [ + liveUnsigned.algorithm, + liveUnsigned.policyChecksum, + ]); useEffect(() => { setForm((prev) => (prev.valuationAlg ? prev : { ...prev, valuationAlg: liveAlgString })); @@ -538,11 +793,7 @@ const ExhaleNote: React.FC = ({ }, [liveAlgString]); const liveQuote = useMemo( - () => - quotePhiForUsd( - { meta, nowPulse: nowFloor, usd: usdSample, currentStreakDays: 0, lifetimeUsdSoFar: 0 }, - policy - ), + () => quotePhiForUsd({ meta, nowPulse: nowFloor, usd: usdSample, currentStreakDays: 0, lifetimeUsdSoFar: 0 }, policy), [meta, nowFloor, usdSample, policy] ); @@ -552,17 +803,109 @@ const ExhaleNote: React.FC = ({ const phiPerUsd = liveQuote.phiPerUsd; const valueUsdIndicative = liveValuePhi * usdPerPhi; - /* Build SVG for preview */ - const buildCurrentSVG = useCallback((): string => { - const usingLocked = Boolean(locked); + const cappedDefaultPhi = useMemo(() => { + if (!isLocked || !locked) return liveValuePhi; + if (typeof availablePhi === "number" && Number.isFinite(availablePhi)) { + return Math.max(0, Math.min(locked.valuePhi, availablePhi)); + } + return locked.valuePhi; + }, [isLocked, locked, liveValuePhi, availablePhi]); + + const defaultSendPhi = isLocked ? cappedDefaultPhi : liveValuePhi; + + const effectiveUsdPerPhi = isLocked && locked ? locked.usdPerPhi : usdPerPhi; + const effectivePhiPerUsd = isLocked && locked ? locked.phiPerUsd : phiPerUsd; - const valuePhiStr = usingLocked ? fTiny(locked!.valuePhi) : fTiny(liveValuePhi); - const premiumPhiStr = usingLocked ? form.premiumPhi || fTiny(livePremium) : fTiny(livePremium); + const defaultSendUsd = useMemo(() => { + const usd = defaultSendPhi * (Number.isFinite(effectiveUsdPerPhi) ? effectiveUsdPerPhi : 0); + return Number.isFinite(usd) && usd > 0 ? usd : 0; + }, [defaultSendPhi, effectiveUsdPerPhi]); - const lockedPulseStr = usingLocked ? String(locked!.lockedPulse) : ""; - const valuationStampStr = usingLocked ? form.valuationStamp || locked!.seal.stamp : ""; + const parsedSendPhi = useMemo(() => (isLocked ? parsePhiInput(sendPhiInput) : null), [isLocked, sendPhiInput]); + const parsedSendUsd = useMemo(() => (isLocked ? parseUsdInput(sendUsdInput) : null), [isLocked, sendUsdInput]); - const verifyUrl = resolveVerifyUrl(form.verifyUrl, defaultVerifyUrl); + const effectiveSendPhi = useMemo(() => { + if (!isLocked) return defaultSendPhi; + + const rate = Number.isFinite(effectiveUsdPerPhi) ? effectiveUsdPerPhi : 0; + if (sendUnit === "phi") { + return parsedSendPhi ?? defaultSendPhi; + } + + // USD → Φ + if (!rate || rate <= 0) return defaultSendPhi; + const usd = parsedSendUsd; + if (usd == null) return defaultSendPhi; + const phi = usd / rate; + if (!Number.isFinite(phi) || phi <= 0) return defaultSendPhi; + return Number(phi.toFixed(6)); + }, [isLocked, sendUnit, parsedSendPhi, parsedSendUsd, defaultSendPhi, effectiveUsdPerPhi]); + + const effectiveValueUsd = useMemo(() => { + const usd = effectiveSendPhi * (Number.isFinite(effectiveUsdPerPhi) ? effectiveUsdPerPhi : 0); + return Number.isFinite(usd) && usd > 0 ? usd : 0; + }, [effectiveSendPhi, effectiveUsdPerPhi]); + + const sendPhiOverBalance = + typeof availablePhi === "number" && Number.isFinite(availablePhi) && effectiveSendPhi > availablePhi + 1e-9; + + /** + * If user has already "committed" a send (via print/save), + * any subsequent amount change must invalidate the prior send result and rotate nonce. + */ + const effectiveSendPhiCanon = useMemo(() => { + return Number.isFinite(effectiveSendPhi) ? effectiveSendPhi.toFixed(6) : ""; + }, [effectiveSendPhi]); + + useEffect(() => { + if (!isLocked) { + lastSendPhiCanonRef.current = effectiveSendPhiCanon; + return; + } + if (lastSendPhiCanonRef.current === effectiveSendPhiCanon) return; + lastSendPhiCanonRef.current = effectiveSendPhiCanon; + + if (!sendCommittedRef.current) return; + sendCommittedRef.current = false; + setNoteSendResult(null); + + const nextNonce = generateNonce(); + setSendNonce(nextNonce); + sendNonceRef.current = nextNonce; + }, [effectiveSendPhiCanon, isLocked]); + + /* Build SVG for preview */ + const buildCurrentSVG = useCallback((): string => { + const usingLocked = isLocked && locked !== null; + + const valuePhiStr = usingLocked ? fTiny(effectiveSendPhi) : fTiny(liveValuePhi); + const valueUsdStr = usingLocked ? fUsd(effectiveValueUsd) : fUsd(valueUsdIndicative); + const premiumPhiStr = usingLocked ? fTiny(effectiveSendPhi) : fTiny(livePremium); + + const lockedPulseStr = usingLocked ? String(locked.lockedPulse) : ""; + const valuationStampStr = usingLocked ? form.valuationStamp || locked.seal.stamp : ""; + + const verifyUrl = resolveNoteVerifyUrl( + { + verifyUrl: form.verifyUrl, + proofBundleJson: form.proofBundleJson, + kaiSignature: form.kaiSignature, + pulse: usingLocked ? locked.lockedPulse : Number(form.computedPulse || nowFloor), + verifiedAtPulse: form.verifiedAtPulse, + }, + defaultVerifyUrl + ); + + const qrPayload = buildQrPayload( + { + verifyUrl: form.verifyUrl, + proofBundleJson: form.proofBundleJson, + kaiSignature: form.kaiSignature, + pulse: usingLocked ? locked.lockedPulse : Number(form.computedPulse || nowFloor), + verifiedAtPulse: form.verifiedAtPulse, + }, + defaultVerifyUrl + ); return buildBanknoteSVG({ purpose: form.purpose, @@ -574,6 +917,7 @@ const ExhaleNote: React.FC = ({ remark: form.remark, valuePhi: valuePhiStr, + valueUsd: valueUsdStr, premiumPhi: premiumPhiStr, computedPulse: lockedPulseStr, @@ -586,9 +930,22 @@ const ExhaleNote: React.FC = ({ sigilSvg: form.sigilSvg || "", verifyUrl, + qrPayload, provenance: form.provenance ?? [], }); - }, [form, liveValuePhi, livePremium, nowFloor, liveAlgString, locked, defaultVerifyUrl]); + }, [ + form, + isLocked, + locked, + liveValuePhi, + livePremium, + valueUsdIndicative, + nowFloor, + liveAlgString, + defaultVerifyUrl, + effectiveSendPhi, + effectiveValueUsd, + ]); const renderPreviewThrottled = useRafThrottle(() => { const host = previewHostRef.current; @@ -613,16 +970,11 @@ const ExhaleNote: React.FC = ({ await new Promise((resolve) => requestAnimationFrame(() => resolve())); const lockedPulse = nowFloor; - const { unsigned: lockedUnsigned } = computeIntrinsicUnsigned(meta, lockedPulse) as { - unsigned: IntrinsicUnsigned; - }; + const { unsigned: lockedUnsigned } = computeIntrinsicUnsigned(meta, lockedPulse) as { unsigned: IntrinsicUnsigned }; const valuationStamp = await computeValuationStamp(lockedUnsigned); - const quote = quotePhiForUsd( - { meta, nowPulse: lockedPulse, usd: usdSample, currentStreakDays: 0, lifetimeUsdSoFar: 0 }, - policy - ); + const quote = quotePhiForUsd({ meta, nowPulse: lockedPulse, usd: usdSample, currentStreakDays: 0, lifetimeUsdSoFar: 0 }, policy); const sealedBase: ValueSeal = materializeStampedSeal(lockedUnsigned as unknown as MaybeUnsignedSeal); const sealed: ValueSeal = { ...sealedBase, stamp: valuationStamp }; @@ -646,6 +998,36 @@ const ExhaleNote: React.FC = ({ lockedRef.current = true; setLocked(payload); + const cap = typeof availablePhi === "number" && Number.isFinite(availablePhi) ? availablePhi : sealed.valuePhi; + const initialPhi = Number(fTiny(Math.max(0, Math.min(sealed.valuePhi, cap)))); + const initialPhiStr = fTiny(Math.max(0, Math.min(sealed.valuePhi, cap))); + + setSendUnit("phi"); + setSendPhiInput(initialPhiStr); + setSendUsdInput(formatUsdInput(initialPhi * quote.usdPerPhi)); + + { + const nextNonce = generateNonce(); + setSendNonce(nextNonce); + sendNonceRef.current = nextNonce; + } + + sendCommittedRef.current = false; + setNoteSendResult(null); + + const proofFields = parseProofBundleJson(form.proofBundleJson); + const sigmaCanon = (form.sigmaCanon || form.kaiSignature || "").trim(); + const shaHex = sigmaCanon ? sha256HexJs(sigmaCanon) : ""; + + let phiDerived = form.phiDerived?.trim() || ""; + if (!phiDerived && sigmaCanon) { + try { + phiDerived = await derivePhiKeyFromSig(sigmaCanon); + } catch { + phiDerived = ""; + } + } + setForm((prev) => ({ ...prev, computedPulse: String(lockedPulse), @@ -654,6 +1036,11 @@ const ExhaleNote: React.FC = ({ premiumPhi: lockedUnsigned.premium !== undefined ? fTiny(lockedUnsigned.premium) : prev.premiumPhi, valuationAlg: prev.valuationAlg || `${lockedUnsigned.algorithm} • ${lockedUnsigned.policyChecksum}`, valuePhi: fTiny(sealed.valuePhi), + sigmaCanon: sigmaCanon || prev.sigmaCanon, + shaHex: shaHex || prev.shaHex, + phiDerived: phiDerived || prev.phiDerived, + verifiedAtPulse: prev.verifiedAtPulse ?? proofFields.verifiedAtPulse ?? lockedPulse, + receiptHash: prev.receiptHash || proofFields.receiptHash || "", verifyUrl: resolveVerifyUrl(prev.verifyUrl, defaultVerifyUrl), })); @@ -665,7 +1052,7 @@ const ExhaleNote: React.FC = ({ } finally { setIsRendering(false); } - }, [nowFloor, meta, usdSample, policy, onRender, isRendering, defaultVerifyUrl]); + }, [nowFloor, meta, usdSample, policy, onRender, isRendering, defaultVerifyUrl, availablePhi, form]); /* Bridge hydration + updates */ const lastBridgeJsonRef = useRef(""); @@ -700,9 +1087,7 @@ const ExhaleNote: React.FC = ({ if (!detail) return; const normalizeIncoming = (obj: Partial) => { - if (typeof obj.verifyUrl === "string") { - obj.verifyUrl = resolveVerifyUrl(obj.verifyUrl, defaultVerifyUrl); - } + if (typeof obj.verifyUrl === "string") obj.verifyUrl = resolveVerifyUrl(obj.verifyUrl, defaultVerifyUrl); return obj; }; @@ -717,12 +1102,16 @@ const ExhaleNote: React.FC = ({ "provenance", "sigilSvg", "verifyUrl", + "proofBundleJson", + "bundleHash", + "receiptHash", + "verifiedAtPulse", + "capsuleHash", + "svgHash", ]; const safe = normalizeIncoming( - Object.fromEntries(Object.entries(detail).filter(([k]) => allow.includes(k as keyof BanknoteInputs))) as Partial< - BanknoteInputs - > + Object.fromEntries(Object.entries(detail).filter(([k]) => allow.includes(k as keyof BanknoteInputs))) as Partial ); const json = JSON.stringify(safe); @@ -753,21 +1142,106 @@ const ExhaleNote: React.FC = ({ }; }, [defaultVerifyUrl]); - /* Print + PNG (require lock) */ + const resolveSendNonce = useCallback((): string => { + if (sendNonceRef.current) return sendNonceRef.current; + const next = sendNonce || generateNonce(); + sendNonceRef.current = next; + if (sendNonce !== next) setSendNonce(next); + return next; + }, [sendNonce]); + + const buildNoteSendPayload = useCallback((): NoteSendPayload | null => { + if (!locked) return null; + const amountPhi = effectiveSendPhi; + if (!Number.isFinite(amountPhi) || amountPhi <= 0) return null; + + const verifyUrl = resolveNoteVerifyUrl( + { + verifyUrl: form.verifyUrl, + proofBundleJson: form.proofBundleJson, + kaiSignature: form.kaiSignature, + pulse: locked.lockedPulse, + verifiedAtPulse: form.verifiedAtPulse, + }, + defaultVerifyUrl + ); + + const transferNonce = resolveSendNonce(); + const merged: Partial = noteSendResult ?? {}; + return { + ...merged, + amountPhi, + amountPhiScaled: toScaledPhi18(amountPhi), + amountUsd: amountPhi * effectiveUsdPerPhi, + lockedPulse: locked.lockedPulse, + valuationStamp: form.valuationStamp || locked.seal.stamp || "", + transferNonce, + verifyUrl, + parentCanonical: originCanonical ?? merged.parentCanonical, + }; + }, [ + locked, + effectiveSendPhi, + effectiveUsdPerPhi, + form.verifyUrl, + form.proofBundleJson, + form.kaiSignature, + form.verifiedAtPulse, + form.valuationStamp, + defaultVerifyUrl, + originCanonical, + resolveSendNonce, + noteSendResult, + ]); + + const ensureNoteSend = useCallback(async (): Promise => { + if (!locked) return false; + if (sendCommittedRef.current) return true; + if (sendPhiOverBalance) { + window.alert("Send amount exceeds the available Φ balance."); + return false; + } + const payload = buildNoteSendPayload(); + if (!payload) { + window.alert("Enter a valid amount to send."); + return false; + } + try { + const result = await onSendNote?.(payload); + if (result) setNoteSendResult(result); + sendCommittedRef.current = true; + return true; + } catch (err) { + window.alert(`Send failed: ${err instanceof Error ? err.message : String(err)}`); + return false; + } + }, [locked, sendPhiOverBalance, buildNoteSendPayload, onSendNote]); + + /* Print + exports (require lock) */ const onPrint = useCallback(async () => { const root = printRootRef.current; if (!root) return; if (!lockedRef.current || !locked) { - window.alert("Please Render to lock the valuation before printing."); + window.alert("Render to lock the note valuation before printing."); return; } + if (!(await ensureNoteSend())) return; - const verifyUrl = resolveVerifyUrl(form.verifyUrl, defaultVerifyUrl); + const verifyUrl = resolveNoteVerifyUrl( + { verifyUrl: form.verifyUrl, proofBundleJson: form.proofBundleJson, kaiSignature: form.kaiSignature, pulse: locked.lockedPulse, verifiedAtPulse: form.verifiedAtPulse }, + defaultVerifyUrl + ); + + const qrPayload = buildQrPayload( + { verifyUrl: form.verifyUrl, proofBundleJson: form.proofBundleJson, kaiSignature: form.kaiSignature, pulse: locked.lockedPulse, verifiedAtPulse: form.verifiedAtPulse }, + defaultVerifyUrl + ); const banknote = buildBanknoteSVG({ ...form, - valuePhi: form.valuePhi || fTiny(locked.valuePhi), - premiumPhi: form.premiumPhi || fTiny(livePremium), + valuePhi: fTiny(effectiveSendPhi), + valueUsd: fUsd(effectiveValueUsd), + premiumPhi: fTiny(effectiveSendPhi), computedPulse: String(locked.lockedPulse), nowPulse: String(locked.lockedPulse), kaiSignature: form.kaiSignature || "", @@ -776,9 +1250,13 @@ const ExhaleNote: React.FC = ({ valuationStamp: form.valuationStamp || locked.seal.stamp, sigilSvg: form.sigilSvg || "", verifyUrl, + qrPayload, provenance: form.provenance ?? [], }); + const noteSendPayload = buildNoteSendPayload(); + const noteSendJson = noteSendPayload ? JSON.stringify(noteSendPayload) : ""; + const proofPages = buildProofPagesHTML({ frozenPulse: String(locked.lockedPulse), kaiSignature: form.kaiSignature || "", @@ -786,14 +1264,22 @@ const ExhaleNote: React.FC = ({ sigmaCanon: form.sigmaCanon || "", shaHex: form.shaHex || "", phiDerived: form.phiDerived || "", - valuePhi: form.valuePhi || fTiny(locked.valuePhi), - premiumPhi: form.premiumPhi || fTiny(livePremium), + valuePhi: fTiny(effectiveSendPhi), + valueUsd: fUsd(effectiveValueUsd), + premiumPhi: fTiny(effectiveSendPhi), valuationAlg: form.valuationAlg || liveAlgString, valuationStamp: form.valuationStamp || locked.seal.stamp, zk: form.zk, provenance: form.provenance ?? [], sigilSvg: form.sigilSvg || "", verifyUrl, + noteSendJson, + proofBundleJson: form.proofBundleJson, + bundleHash: form.bundleHash, + receiptHash: form.receiptHash, + verifiedAtPulse: form.verifiedAtPulse, + capsuleHash: form.capsuleHash, + svgHash: form.svgHash, }); renderIntoPrintRoot(root, banknote, String(locked.lockedPulse), proofPages); @@ -804,21 +1290,75 @@ const ExhaleNote: React.FC = ({ await printWithTempTitle(title); root.setAttribute("aria-hidden", "true"); - }, [form, locked, livePremium, liveAlgString, defaultVerifyUrl]); + }, [form, locked, liveAlgString, defaultVerifyUrl, effectiveSendPhi, effectiveValueUsd, ensureNoteSend, buildNoteSendPayload]); + + const onSaveSvg = useCallback(async () => { + try { + if (!lockedRef.current || !locked) { + window.alert("Render to lock the note valuation before saving SVG."); + return; + } + if (!(await ensureNoteSend())) return; + + const verifyUrl = resolveNoteVerifyUrl( + { verifyUrl: form.verifyUrl, proofBundleJson: form.proofBundleJson, kaiSignature: form.kaiSignature, pulse: locked.lockedPulse, verifiedAtPulse: form.verifiedAtPulse }, + defaultVerifyUrl + ); + + const qrPayload = buildQrPayload( + { verifyUrl: form.verifyUrl, proofBundleJson: form.proofBundleJson, kaiSignature: form.kaiSignature, pulse: locked.lockedPulse, verifiedAtPulse: form.verifiedAtPulse }, + defaultVerifyUrl + ); + + const banknote = buildBanknoteSVG({ + ...form, + valuePhi: fTiny(effectiveSendPhi), + valueUsd: fUsd(effectiveValueUsd), + premiumPhi: fTiny(effectiveSendPhi), + computedPulse: String(locked.lockedPulse), + nowPulse: String(locked.lockedPulse), + kaiSignature: form.kaiSignature || "", + userPhiKey: form.userPhiKey || "", + valuationAlg: form.valuationAlg || liveAlgString, + valuationStamp: form.valuationStamp || locked.seal.stamp, + sigilSvg: form.sigilSvg || "", + verifyUrl, + qrPayload, + provenance: form.provenance ?? [], + }); + + const title = makeFileTitle(form.kaiSignature || "", String(locked.lockedPulse), form.valuationStamp || locked.seal.stamp || ""); + triggerDownload(`${title}.svg`, new Blob([banknote], { type: "image/svg+xml" }), "image/svg+xml"); + } catch (err) { + window.alert("Save SVG failed: " + (err instanceof Error ? err.message : String(err))); + // eslint-disable-next-line no-console + console.error(err); + } + }, [form, locked, liveAlgString, defaultVerifyUrl, effectiveSendPhi, effectiveValueUsd, ensureNoteSend]); const onSavePng = useCallback(async () => { try { if (!lockedRef.current || !locked) { - window.alert("Please Render to lock the valuation before saving PNG."); + window.alert("Render to lock the note valuation before saving PNG."); return; } + if (!(await ensureNoteSend())) return; + + const verifyUrl = resolveNoteVerifyUrl( + { verifyUrl: form.verifyUrl, proofBundleJson: form.proofBundleJson, kaiSignature: form.kaiSignature, pulse: locked.lockedPulse, verifiedAtPulse: form.verifiedAtPulse }, + defaultVerifyUrl + ); - const verifyUrl = resolveVerifyUrl(form.verifyUrl, defaultVerifyUrl); + const qrPayload = buildQrPayload( + { verifyUrl: form.verifyUrl, proofBundleJson: form.proofBundleJson, kaiSignature: form.kaiSignature, pulse: locked.lockedPulse, verifiedAtPulse: form.verifiedAtPulse }, + defaultVerifyUrl + ); const banknote = buildBanknoteSVG({ ...form, - valuePhi: form.valuePhi || fTiny(locked.valuePhi), - premiumPhi: form.premiumPhi || fTiny(livePremium), + valuePhi: fTiny(effectiveSendPhi), + valueUsd: fUsd(effectiveValueUsd), + premiumPhi: fTiny(effectiveSendPhi), computedPulse: String(locked.lockedPulse), nowPulse: String(locked.lockedPulse), kaiSignature: form.kaiSignature || "", @@ -827,267 +1367,722 @@ const ExhaleNote: React.FC = ({ valuationStamp: form.valuationStamp || locked.seal.stamp, sigilSvg: form.sigilSvg || "", verifyUrl, + qrPayload, provenance: form.provenance ?? [], }); const png = await svgStringToPngBlob(banknote, 2400); - const title = makeFileTitle( - form.kaiSignature || "", - String(locked.lockedPulse), - form.valuationStamp || locked.seal.stamp || "" - ); + const proofBundleJson = form.proofBundleJson?.trim() || ""; + const proofFields = parseProofBundleJson(proofBundleJson); + const bundleHash = form.bundleHash || proofFields.bundleHash; + const receiptHash = form.receiptHash || proofFields.receiptHash; + + const title = makeFileTitle(form.kaiSignature || "", String(locked.lockedPulse), form.valuationStamp || locked.seal.stamp || ""); + const noteSendPayload = buildNoteSendPayload(); + const noteSendJson = noteSendPayload ? JSON.stringify(noteSendPayload) : ""; + + const entries = [ + proofBundleJson ? { keyword: "phi_proof_bundle", text: proofBundleJson } : null, + bundleHash ? { keyword: "phi_bundle_hash", text: bundleHash } : null, + receiptHash ? { keyword: "phi_receipt_hash", text: receiptHash } : null, + noteSendJson ? { keyword: "phi_note_send", text: noteSendJson } : null, + { keyword: "phi_note_svg", text: banknote }, + ].filter((entry): entry is { keyword: string; text: string } => Boolean(entry)); + + if (entries.length === 0) { + triggerDownload(`${title}.png`, png, "image/png"); + return; + } - triggerDownload(`${title}.png`, png, "image/png"); + const bytes = new Uint8Array(await png.arrayBuffer()); + const enriched = insertPngTextChunks(bytes, entries); + const finalBlob = new Blob([enriched as BlobPart], { type: "image/png" }); + triggerDownload(`${title}.png`, finalBlob, "image/png"); } catch (err) { window.alert("Save PNG failed: " + (err instanceof Error ? err.message : String(err))); // eslint-disable-next-line no-console console.error(err); } - }, [form, locked, livePremium, liveAlgString, defaultVerifyUrl]); + }, [form, locked, liveAlgString, defaultVerifyUrl, effectiveSendPhi, effectiveValueUsd, ensureNoteSend, buildNoteSendPayload]); /* Derived display values */ - const displayPulse = locked ? locked.lockedPulse : nowFloor; - const displayPhi = locked ? locked.valuePhi : liveValuePhi; - const displayUsd = locked ? locked.valueUsdIndicative : valueUsdIndicative; - const displayUsdPerPhi = locked ? locked.usdPerPhi : usdPerPhi; - const displayPhiPerUsd = locked ? locked.phiPerUsd : phiPerUsd; - const displayPremium = locked ? (form.premiumPhi ? Number(form.premiumPhi) : 0) : livePremium; - const phiParts = formatPhiParts(displayPhi); - + const displayPulse = isLocked && locked ? locked.lockedPulse : nowFloor; + const displayPhi = isLocked ? effectiveSendPhi : liveValuePhi; + const displayUsd = isLocked ? effectiveValueUsd : valueUsdIndicative; + const displayUsdPerPhi = effectiveUsdPerPhi; + const displayPhiPerUsd = effectivePhiPerUsd; + const displayPremium = isLocked ? effectiveSendPhi : livePremium; + + const phiParts = useMemo(() => formatPhiParts(displayPhi), [displayPhi]); const noteTitle = useMemo(() => `☤KAI ${fPulse(displayPulse)}`, [displayPulse]); + /* ──────────────────────────────────────────────────────────────────────────── + Guided Step Composer + ──────────────────────────────────────────────────────────────────────────── */ + + type UiMode = "guide" | "form"; + type GuideFieldKey = "to" | "from" | "purpose" | "location" | "witnesses" | "reference" | "remark"; + type GuideStep = { + key: GuideFieldKey; + label: string; + prompt: string; + placeholder: string; + optional?: boolean; + suggestions?: readonly string[]; + }; + + const guideSteps = useMemo( + () => [ + { key: "to", label: "To", prompt: "Who is this note going to?", placeholder: "Name / handle / organization" }, + { key: "from", label: "From", prompt: "Who is issuing it?", placeholder: "Your name / handle / organization" }, + { + key: "purpose", + label: "Purpose", + prompt: "What is this note for?", + placeholder: "Work, gift, exchange, settlement…", + suggestions: ["work", "gift", "exchange", "settlement", "service", "receipt"], + }, + { key: "location", label: "Location", prompt: "Where was it issued? (optional)", placeholder: "City / place", optional: true }, + { key: "witnesses", label: "Witnesses", prompt: "Any witnesses? (optional)", placeholder: "Names / handles", optional: true }, + { key: "reference", label: "Reference", prompt: "Any reference? (optional)", placeholder: "Invoice, order id, message id…", optional: true }, + { key: "remark", label: "Remark", prompt: "Final remark (optional)", placeholder: "Short line that prints on the note", optional: true }, + ], + [] + ); + + const [uiMode, setUiMode] = useState("guide"); + const [showAdvanced, setShowAdvanced] = useState(false); + + const [guideIdx, setGuideIdx] = useState(0); + const [draft, setDraft] = useState(""); + + const composerRef = useRef(null); + const chatEndRef = useRef(null); + + const currentGuide = guideSteps[Math.max(0, Math.min(guideIdx, guideSteps.length - 1))]; + const guideKey = currentGuide.key; + + const breathStyle = useMemo(() => { + return ({ ["--kk-breath-ms"]: `${Math.max(500, Math.floor(KAI_PULSE_MS))}ms` } as unknown) as CSSProperties; + }, []); + + const setGuideField = useCallback((key: GuideFieldKey, value: string) => { + setForm((prev) => ({ ...prev, [key]: value })); + }, []); + + const askedMaxIdx = useMemo(() => Math.max(0, Math.min(guideIdx, guideSteps.length - 1)), [guideIdx, guideSteps.length]); + + const jumpTo = useCallback( + (idx: number) => { + if (isLocked) return; + const safe = Math.max(0, Math.min(idx, askedMaxIdx)); + setGuideIdx(safe); + }, + [isLocked, askedMaxIdx] + ); + + useEffect(() => { + if (uiMode !== "guide") return; + if (isLocked) return; + setDraft((form[guideKey] ?? "").toString()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [uiMode, guideKey, isLocked]); + + useEffect(() => { + if (uiMode !== "guide") return; + if (isLocked) return; + composerRef.current?.focus({ preventScroll: true }); + }, [uiMode, guideIdx, isLocked]); + + useEffect(() => { + if (uiMode !== "guide") return; + chatEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); + }, [uiMode, guideIdx, isLocked]); + + const answeredCount = useMemo(() => { + let n = 0; + for (const s of guideSteps) if ((form[s.key] ?? "").trim()) n += 1; + return n; + }, [form, guideSteps]); + + type TranscriptItem = + | { id: string; side: "sys"; text: string; stepIdx?: number } + | { id: string; side: "you"; text: string; stepIdx?: number }; + + const transcript = useMemo(() => { + const items: TranscriptItem[] = []; + + items.push({ + id: "intro", + side: "sys", + text: "Step mode: past answers stay visible. Only the current question is open.", + }); + + // Only show questions up to the current step (no future). + for (let i = 0; i <= askedMaxIdx; i++) { + const step = guideSteps[i]; + items.push({ id: `q:${step.key}`, side: "sys", text: step.prompt, stepIdx: i }); + + const val = (form[step.key] ?? "").trim(); + + // Only show answers for steps strictly before the current guideIdx. + if (i < guideIdx) { + if (val) items.push({ id: `a:${step.key}`, side: "you", text: val, stepIdx: i }); + else if (step.optional) items.push({ id: `a:${step.key}:empty`, side: "you", text: "—", stepIdx: i }); + } + } + + if (!isLocked) { + items.push({ id: "hint", side: "sys", text: "Answer above. Render locks the note valuation at the current Kai pulse." }); + } else if (locked) { + items.push({ + id: "lockedhint", + side: "sys", + text: `Locked at ☤KAI ${fPulse(locked.lockedPulse)}. Send Amount is the only editable value now.`, + }); + } + + return items; + }, [guideSteps, form, guideIdx, askedMaxIdx, isLocked, locked]); + + const commitGuide = useCallback(() => { + if (isLocked) return; + + const text = draft.trim(); + if (!text && !currentGuide.optional) return; + + setGuideField(guideKey, text); + setDraft(""); + setGuideIdx((prev) => Math.min(prev + 1, guideSteps.length - 1)); + }, [isLocked, draft, currentGuide.optional, guideKey, guideSteps.length, setGuideField]); + + const skipGuide = useCallback(() => { + if (isLocked) return; + if (!currentGuide.optional) return; + + setGuideField(guideKey, ""); + setDraft(""); + setGuideIdx((prev) => Math.min(prev + 1, guideSteps.length - 1)); + }, [isLocked, currentGuide.optional, guideKey, guideSteps.length, setGuideField]); + + const backGuide = useCallback(() => { + if (isLocked) return; + setDraft(""); + setGuideIdx((prev) => Math.max(0, prev - 1)); + }, [isLocked]); + + const quickFill = useCallback( + (val: string) => { + if (isLocked) return; + const v = val.trim(); + if (!v && !currentGuide.optional) return; + setGuideField(guideKey, v); + setDraft(""); + setGuideIdx((prev) => Math.min(prev + 1, guideSteps.length - 1)); + }, + [isLocked, currentGuide.optional, guideKey, guideSteps.length, setGuideField] + ); + + /** + * Send unit toggle: + * - Only enabled after lock + * - Switching keeps the underlying amount (Φ) consistent by translating current effective φ to the other unit. + */ + const setSendUnitSafe = useCallback( + (next: SendUnit) => { + if (!isLocked) return; + if (next === sendUnit) return; + + const phi = effectiveSendPhi; + const rate = Number.isFinite(effectiveUsdPerPhi) ? effectiveUsdPerPhi : 0; + + if (next === "phi") { + setSendPhiInput(Number.isFinite(phi) && phi > 0 ? fTiny(phi) : fTiny(defaultSendPhi)); + } else { + const usd = rate > 0 ? phi * rate : defaultSendUsd; + setSendUsdInput(formatUsdInput(usd)); + } + + setSendUnit(next); + }, + [isLocked, sendUnit, effectiveSendPhi, effectiveUsdPerPhi, defaultSendPhi, defaultSendUsd] + ); + /* UI */ return ( -
- {/* Header */} -
-
- ☤KAI — Kairos Kurrensy · Sovereign Harmonik Kingdom +
+ {/* ───────────────────────────────────────────────────────────── + PREMIUM ONE-ROW HEADER (Atlantean glass icon pills) + ───────────────────────────────────────────────────────────── */} +
+
+
+ +
+ +
+ {isLocked ? : } +
+ +
+ + {fPulse(displayPulse)} +
+ +
+ + {fTiny(displayPhi)} +
+ +
+ {fUsd(displayUsd)} +
+ +
+ + {answeredCount}/{guideSteps.length} + +
-
Issued under Yahuah’s Law of Eternal Light (Φ • Kai-Turah)
-
- {/* Pricing hero */} -
-
- {locked ? "LOCKED" : "LIVE"} - ☤KAI {fPulse(displayPulse)} - value: {fPhi(displayPhi)} - $ / φ: {fTiny(displayUsdPerPhi)} - φ / $: {fTiny(displayPhiPerUsd)} - premium φ: {fTiny(displayPremium)} +
+
+ + +
+ +
+
-
-
-
VALUE
-
- Φ - {phiParts.int} - {phiParts.frac} + {/* ───────────────────────────────────────────────────────────── + HERO: value, lock, actions + ───────────────────────────────────────────────────────────── */} +
+
+
+
+ + {isLocked ? : } + + + + {fPulse(displayPulse)} + + + {fTiny(displayUsdPerPhi)} + + + {fTiny(displayPhiPerUsd)} + +
+ +
+
VALUE
+
+ Φ + {phiParts.int} + {phiParts.frac} +
+
≈ {fUsd(displayUsd)}
-
≈ {fUsd(displayUsd)}
-
- {!locked ? ( +
+ {!isLocked ? ( ) : ( -
-
Valuation Locked
-
- ☤KAI {fPulse(locked.lockedPulse)} • Hash: {form.valuationStamp || locked.seal.stamp || "—"} +
+
Locked
+
+ ☤KAI {locked ? fPulse(locked.lockedPulse) : fPulse(displayPulse)} · stamp{" "} + {form.valuationStamp || locked?.seal.stamp || "—"}
)} -
- - +
-
- {/* Immutable title */} -
- - -
+ {/* TOP ANSWER BOX + SEND AMOUNT (side-by-side) */} +
+ {uiMode === "guide" ? ( +
+
+
+ + {isLocked ? "LOCKED" : `${guideIdx + 1}/${guideSteps.length}`} +
+
+ {currentGuide.label} +
+
- {/* Printed metadata */} -
-
-
- - u("purpose")(e.target.value)} - placeholder="e.g., consideration for work / gift / exchange" - disabled={!!locked} - /> -
-
- - u("to")(e.target.value)} placeholder="Recipient" disabled={!!locked} /> -
-
- - u("from")(e.target.value)} placeholder="Issuer" disabled={!!locked} /> +
{isLocked ? "Locked — only Send Amount can change." : currentGuide.prompt}
+ +
+ setDraft(e.target.value)} + placeholder={isLocked ? "Locked" : currentGuide.placeholder} + disabled={isLocked} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commitGuide(); + } + }} + /> + +
+ + + {currentGuide.optional ? ( + + ) : null} + + +
+
+ + {currentGuide.suggestions && !isLocked ? ( +
+ {currentGuide.suggestions.map((s) => ( + + ))} +
+ ) : null} +
+ ) : null} + + {/* Send Amount */} +
+
+
Send Amount
+
Committed when printing/saving exports.
+
+ +
+ {/* Unit toggle */} +
+ + +
+ +
+ {sendUnit === "phi" ? "Φ" : "$"} + { + if (sendUnit === "phi") setSendPhiInput(e.target.value); + else setSendUsdInput(e.target.value); + }} + placeholder={ + !isLocked + ? "Render to set amount" + : sendUnit === "phi" + ? fTiny(defaultSendPhi) + : formatUsdInput(defaultSendUsd) + } + disabled={!isLocked} + className={`kk-sendbar__input ${sendPhiOverBalance ? "is-error" : ""}`} + inputMode="decimal" + aria-invalid={sendPhiOverBalance || undefined} + /> +
+ +
+
+ {sendUnit === "phi" ? ( + <>≈ {fUsd(effectiveValueUsd)} + ) : ( + <> + ≈ Φ {fTiny(effectiveSendPhi)} + + )} +
+ + {isLocked && typeof availablePhi === "number" && Number.isFinite(availablePhi) ? ( +
+ Available: {fTiny(availablePhi)} {sendPhiOverBalance ? "· exceeds" : ""} +
+ ) : ( +
Render locks valuation.
+ )} +
+
+
-
-
- - u("location")(e.target.value)} - placeholder="(optional)" - disabled={!!locked} - /> + {/* Guided vs Form */} + {uiMode === "guide" ? ( +
+
+
+ Guide {isLocked ? "locked" : "live"} +
+
Only past Q/A + current question appear. Future steps stay hidden until reached.
-
- - u("witnesses")(e.target.value)} - placeholder="(optional)" - disabled={!!locked} - /> + +
+ {transcript.map((m) => { + const canJump = typeof m.stepIdx === "number" && !isLocked && m.stepIdx < guideIdx; + return ( +
+
+
{m.text}
+ {canJump && m.side === "sys" ? ( + + ) : null} +
+
+ ); + })} + +
+
+
Live Note Preview
+
Updates as you answer · locks on Render
+
+
+
+ +
+
+ ) : ( +
- - u("reference")(e.target.value)} - placeholder="(optional)" - disabled={!!locked} - /> + +
-
-
- -
- - u("remark")(e.target.value)} - placeholder="In Yahuah We Trust — Secured by Φ, not man-made law" - disabled={!!locked} - /> -
-
- - Identity & Valuation — appears on the bill + proof pages - - -
-
-
- - -
-
- - -
-
- - +
+
+
+ + u("purpose")(e.target.value)} placeholder="Work / gift / exchange" disabled={isLocked} /> +
+
+ + u("to")(e.target.value)} placeholder="Recipient" disabled={isLocked} /> +
+
+ + u("from")(e.target.value)} placeholder="Issuer" disabled={isLocked} /> +
-
- - + +
+
+ + u("location")(e.target.value)} placeholder="(optional)" disabled={isLocked} /> +
+
+ + u("witnesses")(e.target.value)} placeholder="(optional)" disabled={isLocked} /> +
+
+ + u("reference")(e.target.value)} placeholder="(optional)" disabled={isLocked} /> +
-
-
- - -
-
- - -
-
- - u("kaiSignature")(e.target.value)} disabled={!!locked} /> -
-
- - u("userPhiKey")(e.target.value)} disabled={!!locked} /> +
+ + u("remark")(e.target.value)} placeholder="Short line printed on the note" disabled={isLocked} /> +
+ +
+
+
Live Note Preview
+
Updates as you edit · locks on Render
-
- - u("sigmaCanon")(e.target.value)} disabled={!!locked} /> +
+
+ + )} + + {/* Advanced proof panel (toggle in header) */} + {showAdvanced ? ( +
+
+ + Identity & Valuation — printed on the note + proof pages + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + u("kaiSignature")(e.target.value)} disabled={isLocked} /> +
+
+ + u("userPhiKey")(e.target.value)} disabled={isLocked} /> +
+
+ + u("sigmaCanon")(e.target.value)} disabled={isLocked} /> +
+
+ + u("shaHex")(e.target.value)} disabled={isLocked} /> +
+
+ + u("phiDerived")(e.target.value)} disabled={isLocked} /> +
+
+
- - u("shaHex")(e.target.value)} disabled={!!locked} /> + + u("verifyUrl")(e.target.value)} placeholder="Used for QR & clickable sigil" disabled={isLocked} />
+
- - u("phiDerived")(e.target.value)} disabled={!!locked} /> + +