diff --git a/public/sw.js b/public/sw.js index b69e80853..25efb84ae 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.7.0"; // update on release +const APP_VERSION = "43.0.0"; // update on release const VERSION = new URL(self.location.href).searchParams.get("v") || APP_VERSION; // derived from build const PREFIX = "PHINETWORK"; diff --git a/src/App.tsx b/src/App.tsx index 417ad6fb2..32ebd785e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1638,36 +1638,74 @@ export function AppChrome(): React.JSX.Element {
Atrium
Breath-Sealed Identity · Kairos-ZK Proof
+
+ {(() => { + const isMintPhiKey = (item: { to: string; label: string }) => + item.to === "/mint" || + (item.label.toLowerCase().includes("mint") && + item.label.toLowerCase().includes("phi")); + + const hasMint = navItems.some(isMintPhiKey); + + return ( + <> + {navItems.map((item) => ( + + + `nav-item ${isActive ? "nav-item--active" : ""}` + } + aria-label={`${item.label}: ${item.desc}`} + onPointerEnter={item.to === "/keystream" ? prefetchSigilExplorer : undefined} + onFocus={item.to === "/keystream" ? prefetchSigilExplorer : undefined} + onTouchStart={item.to === "/keystream" ? prefetchSigilExplorer : undefined} + onPointerDown={item.to === "/keystream" ? prefetchSigilExplorer : undefined} + > +
{item.label}
+
{item.desc}
+
+ + {/* ✅ Insert Attestation right after Mint PhiKey */} + {hasMint && isMintPhiKey(item) && ( + + )} +
+ ))} + + {/* Fallback: if we can't find Mint, keep Attestation at end */} + {!hasMint && ( + + )} + + ); + })()} +
-
- {navItems.map((item) => ( - `nav-item ${isActive ? "nav-item--active" : ""}`} - aria-label={`${item.label}: ${item.desc}`} - onPointerEnter={item.to === "/keystream" ? prefetchSigilExplorer : undefined} - onFocus={item.to === "/keystream" ? prefetchSigilExplorer : undefined} - onTouchStart={item.to === "/keystream" ? prefetchSigilExplorer : undefined} - onPointerDown={item.to === "/keystream" ? prefetchSigilExplorer : undefined} - > -
{item.label}
-
{item.desc}
-
- ))} - - -
diff --git a/src/components/ExhaleNote.css b/src/components/ExhaleNote.css index a8505daf3..2412c85c1 100644 --- a/src/components/ExhaleNote.css +++ b/src/components/ExhaleNote.css @@ -1,12 +1,10 @@ /* ───────────────────────────────────────────────────────────────────────────── - 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) + ExhaleNote.css — Atlantean Glass Note Composer (v26.3.1) + FINAL PRODUCTION STYLES — UNIT TOGGLE + CENTERED AMOUNTS FIX + - FIX: Send Amount area now uses a grid layout so nothing clips + - USD/Φ display is centered + always visible (no right-edge cut-off) + - Unit toggle + input stay aligned and responsive + - Everything remains scoped to .kk-note ───────────────────────────────────────────────────────────────────────────── */ /* ───────────────────────────────────────────────────────────────────────────── @@ -73,7 +71,6 @@ position: relative; isolation: isolate; - /* nicer text rendering */ text-rendering: geometricPrecision; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; @@ -105,13 +102,9 @@ 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; @@ -163,11 +156,8 @@ mask-image: linear-gradient(90deg, #000 80%, transparent 100%); } -.kk-headbar__right { - flex: 0 0 auto; -} +.kk-headbar__right { flex: 0 0 auto; } -/* Pill base (header) */ .kk-pill { display: inline-flex; align-items: center; @@ -178,11 +168,8 @@ 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); + 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; @@ -191,7 +178,6 @@ .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); @@ -214,7 +200,6 @@ 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); @@ -242,29 +227,19 @@ 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--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); -} +.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-pill--mode { padding: 6px; gap: 6px; } .kk-iconBtn { appearance: none; @@ -295,12 +270,8 @@ box-shadow: 0 10px 22px rgba(86,255,227,0.14); } -.kk-iconBtn:focus-visible { - outline: none; - box-shadow: var(--kk-focus); -} +.kk-iconBtn:focus-visible { outline: none; box-shadow: var(--kk-focus); } -/* Shield pill toggle */ .kk-pill--shield { width: 38px; height: 34px; @@ -319,7 +290,6 @@ 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; } @@ -473,7 +443,7 @@ } /* ───────────────────────────────────────────────────────────────────────────── - Buttons (kept compatible) + Buttons ───────────────────────────────────────────────────────────────────────────── */ .kk-btn { @@ -510,7 +480,6 @@ .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; @@ -530,10 +499,7 @@ padding: 10px 12px; } -.kk-lockcard__t { - font-weight: 950; - color: var(--kk-gold); -} +.kk-lockcard__t { font-weight: 950; color: var(--kk-gold); } .kk-lockcard__s { margin-top: 4px; @@ -543,7 +509,7 @@ } /* ───────────────────────────────────────────────────────────────────────────── - Dual bar: Step answer (left) + Send Amount (right) + Dual bar: Step answer + Send Amount ───────────────────────────────────────────────────────────────────────────── */ .kk-dualbar { @@ -632,10 +598,7 @@ transform: translateY(-1px); } -.kk-qaInput:disabled { - opacity: 0.70; - cursor: not-allowed; -} +.kk-qaInput:disabled { opacity: 0.70; cursor: not-allowed; } .kk-qaBtns { display: inline-flex; @@ -645,7 +608,6 @@ flex-wrap: wrap; } -/* Suggestions */ .kk-suggest { margin-top: 10px; display: flex; @@ -667,23 +629,20 @@ .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 + Send Amount bar — FIXED LAYOUT (CENTERED + NO CLIP) ───────────────────────────────────────────────────────────────────────────── */ .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; + padding: 12px 12px 10px; position: relative; + + /* Keep glow inside the card but stop clipping content by preventing overflow */ overflow: hidden; } @@ -698,9 +657,7 @@ pointer-events: none; } -@media (max-width: 720px) { - .kk-sendbar { flex-direction: column; align-items: stretch; } -} +.kk-sendbar__left { min-width: 0; position: relative; z-index: 1; } .kk-sendbar__label { font-weight: 950; @@ -713,16 +670,55 @@ margin-top: 2px; } -.kk-sendbar__right { - display: flex; +/* ✅ Key fix: + - Use GRID so meta never overflows off the right edge. + - Meta is centered under the controls (always visible). +*/ +.kk-sendbar { + display: grid; + grid-template-columns: minmax(180px, 1fr) minmax(0, 1.35fr); gap: 12px; align-items: center; +} + +.kk-sendbar__right { position: relative; - z-index: 1; /* above sendbar::before */ + z-index: 1; + + display: grid; + grid-template-columns: auto minmax(0, 1fr); + grid-template-areas: + "unit input" + "meta meta"; + column-gap: 12px; + row-gap: 8px; + align-items: center; + + /* Prevent edge clipping inside narrow modals */ + min-width: 0; } +.kk-sendbar__unit { grid-area: unit; } +.kk-sendbar__inputWrap { grid-area: input; } +.kk-sendbar__meta { grid-area: meta; } + @media (max-width: 720px) { - .kk-sendbar__right { justify-content: space-between; } + .kk-sendbar { + grid-template-columns: 1fr; + gap: 10px; + align-items: start; + } + + .kk-sendbar__right { + grid-template-columns: 1fr; + grid-template-areas: + "unit" + "input" + "meta"; + justify-items: center; + } + + .kk-sendbar__unit { justify-self: center; } } /* Unit segmented control */ @@ -733,8 +729,7 @@ 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)); + 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); } @@ -779,6 +774,7 @@ .kk-sendbar__unitBtn:focus-visible { outline: none; box-shadow: var(--kk-focus); } +/* Input wrap + input */ .kk-sendbar__inputWrap { display: inline-flex; align-items: center; @@ -789,6 +785,11 @@ background: rgba(24, 30, 40, 0.65); box-shadow: inset 0 1px 0 rgba(255,255,255,0.03); min-height: 42px; + + /* ✅ prevents overflow on narrow layouts */ + width: min(340px, 100%); + min-width: 0; + justify-self: center; } .kk-sendbar__prefix { @@ -797,7 +798,8 @@ } .kk-sendbar__input { - width: 170px; + width: 100%; + min-width: 120px; border: 0; outline: none; background: transparent; @@ -810,32 +812,38 @@ .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) { +/* Focus ring without relying on :has (Safari/Firefox safe) */ +.kk-sendbar__inputWrap:focus-within { border-color: rgba(86,255,227,0.60); box-shadow: var(--kk-focus); } +/* Meta (centered + never clipped) */ .kk-sendbar__meta { display: grid; - gap: 2px; - justify-items: end; - min-width: 140px; + gap: 3px; + justify-items: center; + text-align: center; + + min-width: 0; + max-width: 100%; } .kk-sendbar__usd { font-weight: 950; font-variant-numeric: tabular-nums; + + /* ✅ allow wrap instead of clipping */ + max-width: 100%; + overflow-wrap: anywhere; + line-height: 1.15; } .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); + max-width: 100%; + overflow-wrap: anywhere; } /* ───────────────────────────────────────────────────────────────────────────── @@ -889,7 +897,6 @@ 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); @@ -899,11 +906,7 @@ .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 { display: flex; margin: 10px 0; } .kk-bubbleRow.is-sys { justify-content: flex-start; } .kk-bubbleRow.is-you { justify-content: flex-end; } @@ -1058,10 +1061,7 @@ } .kk-row input:disabled, -.kk-row textarea:disabled { - opacity: 0.75; - cursor: not-allowed; -} +.kk-row textarea:disabled { opacity: 0.75; cursor: not-allowed; } .kk-out { font-family: var(--kk-font-mono); } @@ -1082,10 +1082,390 @@ .kk-headbar, .kk-hero2, .kk-chatpanel, - .kk-formpanel { - display: none !important; - } + .kk-formpanel { display: none !important; } #print-root { display: block !important; } body { background: #fff !important; } } +/* ───────────────────────────────────────────────────────────── + v26.3.x COMPACT HERO OVERRIDES + - Shrinks Render/Lock + Export controls + - Reduces VALUE block height + - Keeps everything visible while reclaiming vertical space + ───────────────────────────────────────────────────────────── */ + +.kk-hero2{ + margin-top: 10px; + padding: 10px; +} + +.kk-hero2__row{ + gap: 10px; + align-items: start; + grid-template-columns: 1fr minmax(240px, 34%); +} + +@media (max-width: 980px){ + .kk-hero2__row{ grid-template-columns: 1fr; } +} + +/* Status chips are duplicated by the headbar — hide them on small to save height */ +@media (max-width: 720px){ + .kk-hero2__status{ display: none; } +} + +/* Tighten chips when visible */ +.kk-hero2__status{ + margin-bottom: 6px; + gap: 6px; +} + +.kk-chip2{ + font-size: 11px; + padding: 5px 8px; + gap: 5px; +} + +/* VALUE block: smaller but still “premium” */ +.kk-hero2__big{ + padding: 10px 12px; +} + +.kk-big__label{ + font-size: 11px; + letter-spacing: 2.2px; + margin-bottom: 4px; +} + +.kk-big__num{ + gap: 8px; +} + +.kk-big__phi{ + font-size: clamp(20px, 4.2vw, 32px); +} + +.kk-big__int{ + font-size: clamp(30px, 7.2vw, 52px); +} + +.kk-big__frac{ + font-size: clamp(13px, 2.8vw, 20px); + padding-bottom: 2px; +} + +.kk-big__usd{ + margin-top: 4px; + font-size: 12px; +} + +/* ───────────────────────────────────────────────────────────── + Compact action row + ───────────────────────────────────────────────────────────── */ + +.kk-hero2__actions{ + align-content: start; +} + +/* One refined strip for Render/Lock + tools */ +.kk-actionsRow{ + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +/* Tools cluster stays tight */ +.kk-actionsRow__tools{ + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +/* Render button: compact primary */ +.kk-btn-compact{ + padding: 10px 12px; + font-size: 14px; + border-radius: 12px; + min-height: 40px; +} + +/* Export buttons: mini pills (still finger-safe) */ +.kk-btn-mini{ + padding: 8px 10px; + font-size: 13px; + border-radius: 11px; + min-height: 36px; +} + +/* Locked card: slimmer */ +.kk-lockcard--compact{ + padding: 8px 10px; + border-radius: 14px; +} + +.kk-lockcard--compact .kk-lockcard__t{ + font-size: 12px; +} + +.kk-lockcard--compact .kk-lockcard__s{ + font-size: 11px; + margin-top: 3px; +} + +.kk-lockcard__dot{ + margin: 0 6px; + opacity: 0.7; +} + +.kk-lockcard__k{ + opacity: 0.85; +} + +/* Mobile: keep it compact and readable */ +@media (max-width: 560px){ + .kk-actionsRow{ + justify-content: stretch; + } + + /* Render takes full width, tools below in one tight row */ + .kk-actionsRow > .kk-btn-compact, + .kk-actionsRow > .kk-lockcard--compact{ + flex: 1 1 100%; + } + + .kk-actionsRow__tools{ + width: 100%; + justify-content: space-between; + gap: 8px; + } + + .kk-actionsRow__tools .kk-btn-mini{ + flex: 1 1 0; + justify-content: center; + } +} +/* ───────────────────────────────────────────────────────────── + VALUE BOX: inline Lock control (compact) + ───────────────────────────────────────────────────────────── */ + +.kk-big__top{ + display:flex; + align-items:center; + justify-content:space-between; + gap:10px; + margin-bottom: 4px; +} + +.kk-big__label{ + margin:0; +} + +.kk-big__lockSlot{ + display:flex; + align-items:center; + justify-content:flex-end; + min-width: 0; +} + +/* Small Lock button that lives on the VALUE box */ +.kk-lockBtn{ + appearance:none; + border: 1px solid rgba(86,255,227,0.34); + background: linear-gradient(180deg, rgba(86,255,227,0.18), rgba(86,255,227,0.08)); + color: rgba(234,241,255,0.96); + + padding: 6px 10px; + border-radius: 999px; + font-weight: 950; + font-size: 12px; + letter-spacing: 0.14px; + + min-height: 30px; + line-height: 1; + cursor:pointer; + + box-shadow: 0 10px 22px rgba(86,255,227,0.10), inset 0 1px 0 rgba(255,255,255,0.08); + 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-lockBtn:hover{ + transform: translateY(-1px); + border-color: rgba(86,255,227,0.48); + filter: brightness(1.03); + box-shadow: 0 14px 28px rgba(86,255,227,0.14), inset 0 1px 0 rgba(255,255,255,0.10); +} + +.kk-lockBtn:active{ + transform: translateY(0); +} + +.kk-lockBtn:focus-visible{ + outline:none; + box-shadow: var(--kk-focus); +} + +.kk-lockBtn[disabled]{ + opacity: 0.62; + cursor: not-allowed; + transform: none; + filter: none; + box-shadow: 0 10px 22px rgba(0,0,0,0.14), inset 0 1px 0 rgba(255,255,255,0.06); +} + +/* Locked pill (tiny, informational, non-bulky) */ +.kk-lockPill{ + display:inline-flex; + align-items:center; + gap: 6px; + + border: 1px solid rgba(245,217,141,0.34); + background: linear-gradient(180deg, rgba(245,217,141,0.14), rgba(245,217,141,0.06)); + color: rgba(234,241,255,0.92); + + padding: 6px 10px; + border-radius: 999px; + + font-size: 11px; + font-weight: 900; + min-height: 30px; + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + box-shadow: 0 10px 22px rgba(245,217,141,0.08), inset 0 1px 0 rgba(255,255,255,0.08); +} + +.kk-lockPill__k{ opacity: 0.92; } +.kk-lockPill__dot{ opacity: 0.70; } +.kk-lockPill__mono{ font-variant-numeric: tabular-nums; } + +/* Right column tools stay compact and aligned */ +.kk-hero2__actions{ + display:flex; + align-items:flex-start; + justify-content:flex-end; +} + +.kk-actionsRow__tools{ + display:inline-flex; + align-items:center; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +/* Mobile: exports become full-width row buttons */ +@media (max-width: 560px){ + .kk-actionsRow__tools{ + width: 100%; + justify-content: space-between; + } + .kk-actionsRow__tools .kk-btn-mini{ + flex: 1 1 0; + justify-content: center; + } +} +/* ───────────────────────────────────────────────────────────── + Send Amount — COMPACT + SLEEK (paste at end of ExhaleNote.css) + ───────────────────────────────────────────────────────────── */ + +.kk-sendbar--compact{ + padding: 10px 12px 9px; +} + +/* tighter left text */ +.kk-sendbar--compact .kk-sendbar__label{ + font-size: 13px; + font-weight: 950; +} +.kk-sendbar--compact .kk-sendbar__sub{ + margin-top: 2px; + font-size: 11.5px; + line-height: 1.15; +} + +/* tighter grid inside right side */ +.kk-sendbar--compact .kk-sendbar__right{ + row-gap: 6px; + column-gap: 10px; +} + +/* unit toggle: smaller pill */ +.kk-sendbar--compact .kk-sendbar__unit{ + padding: 5px; + gap: 6px; +} + +/* perfect center for Φ / $ */ +.kk-sendbar--compact .kk-sendbar__unitBtn{ + width: 36px; + height: 28px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + text-align: center; +} + +/* optical centering (Φ usually sits low) */ +.kk-sendbar--compact .kk-unitGlyph{ + display: block; + line-height: 1; + transform: translateY(-0.06em); + font-weight: 950; +} + +/* compact input wrap */ +.kk-sendbar--compact .kk-sendbar__inputWrap{ + min-height: 38px; + padding: 7px 10px; + width: min(320px, 100%); +} + +/* slightly smaller input text */ +.kk-sendbar--compact .kk-sendbar__input{ + font-size: 13.5px; +} + +/* meta: tighter, no wasted height */ +.kk-sendbar--compact .kk-sendbar__meta{ + gap: 2px; +} + +.kk-sendbar--compact .kk-sendbar__usd{ + font-size: 12.5px; + line-height: 1.1; +} + +.kk-sendbar--compact .kk-sendbar__hint{ + font-size: 11.5px; + line-height: 1.1; +} + +.kk-sendbar__warn{ + color: rgba(255, 165, 185, 0.92); + font-weight: 850; +} + +/* Mobile: keep it stacked but tight */ +@media (max-width: 720px){ + .kk-sendbar--compact{ + padding: 10px 12px; + } + .kk-sendbar--compact .kk-sendbar__right{ + justify-items: center; + row-gap: 8px; + } + .kk-sendbar--compact .kk-sendbar__inputWrap{ + width: 100%; + } +} diff --git a/src/components/ExhaleNote.tsx b/src/components/ExhaleNote.tsx index 14267db4c..8a31bee99 100644 --- a/src/components/ExhaleNote.tsx +++ b/src/components/ExhaleNote.tsx @@ -79,7 +79,7 @@ function makeFileTitle(kaiSig: string, pulse: string, stamp: string): string { .replace(/-+/g, "-") .slice(0, 180); - return `KAI-${safe(pulse)}-SIGIL-${safe(serialCore)}—VAL-${safe(stamp)}`; + return `☤KAI-NOTE-${safe(pulse)}-${safe(serialCore)}—VAL-${safe(stamp)}`; } function formatPhiParts(val: number): { int: string; frac: string } { @@ -1705,50 +1705,63 @@ const ExhaleNote: React.FC = ({ {fTiny(displayPhiPerUsd)}
+
+
+
VALUE
+ +
+ {!isLocked ? ( + + ) : ( +
+ Minted + · + + ☤KAI {locked ? fPulse(locked.lockedPulse) : fPulse(displayPulse)} + +
+ )} +
+
+ +
+ Φ + {phiParts.int} + {phiParts.frac} +
+ +
≈ {fUsd(displayUsd)}
+
+ +
+
+ + + +
+
+ + -
-
VALUE
-
- Φ - {phiParts.int} - {phiParts.frac} -
-
≈ {fUsd(displayUsd)}
-
- - -
- {!isLocked ? ( - - ) : ( -
-
Locked
-
- ☤KAI {locked ? fPulse(locked.lockedPulse) : fPulse(displayPulse)} · stamp{" "} - {form.valuationStamp || locked?.seal.stamp || "—"} -
-
- )} - -
- - - -
-
{/* TOP ANSWER BOX + SEND AMOUNT (side-by-side) */} @@ -1758,14 +1771,14 @@ const ExhaleNote: React.FC = ({
- {isLocked ? "LOCKED" : `${guideIdx + 1}/${guideSteps.length}`} + {isLocked ? "MINTED" : `${guideIdx + 1}/${guideSteps.length}`}
{currentGuide.label}
-
{isLocked ? "Locked — only Send Amount can change." : currentGuide.prompt}
+
{isLocked ? "Minted — only Send Amount can change." : currentGuide.prompt}
= ({ className="kk-qaInput" value={isLocked ? "" : draft} onChange={(e) => setDraft(e.target.value)} - placeholder={isLocked ? "Locked" : currentGuide.placeholder} + placeholder={isLocked ? "Minted" : currentGuide.placeholder} disabled={isLocked} onKeyDown={(e) => { if (e.key === "Enter") { @@ -1832,82 +1845,86 @@ const ExhaleNote: React.FC = ({ ) : null}
) : null} +{/* Send Amount — compact + centered unit toggle */} +
+
+
Exhale Amount
+
Minted on Print/SVG/PNG.
+
+ +
+ {/* Unit toggle */} +
+ + + +
- {/* Send Amount */} -
-
-
Send Amount
-
Committed when printing/saving exports.
-
- -
- {/* Unit toggle */} -
- - -
+ {/* Amount input */} +
+ + { + if (sendUnit === "phi") setSendPhiInput(e.target.value); + else setSendUsdInput(e.target.value); + }} + placeholder={ + !isLocked + ? "Mint 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" ? "Φ" : "$"} - { - 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} - /> -
+ {/* Meta (single tight line + optional hint) */} +
+
+ {sendUnit === "phi" ? ( + <>≈ {fUsd(effectiveValueUsd)} + ) : ( + <> + ≈ Φ {fTiny(effectiveSendPhi)} + + )} +
-
-
- {sendUnit === "phi" ? ( - <>≈ {fUsd(effectiveValueUsd)} - ) : ( - <> - ≈ Φ {fTiny(effectiveSendPhi)} - - )} -
+ {isLocked && typeof availablePhi === "number" && Number.isFinite(availablePhi) ? ( +
+ Avail {fTiny(availablePhi)} + {sendPhiOverBalance ? · exceeds : null} +
+ ) : ( +
Mint locks valuation.
+ )} +
+
+
- {isLocked && typeof availablePhi === "number" && Number.isFinite(availablePhi) ? ( -
- Available: {fTiny(availablePhi)} {sendPhiOverBalance ? "· exceeds" : ""} -
- ) : ( -
Render locks valuation.
- )} -
-
-
diff --git a/src/components/SigilExplorer/inhaleQueue.ts b/src/components/SigilExplorer/inhaleQueue.ts index afc02fa3c..e8ea27c4f 100644 --- a/src/components/SigilExplorer/inhaleQueue.ts +++ b/src/components/SigilExplorer/inhaleQueue.ts @@ -3,10 +3,26 @@ import { apiFetchWithFailover, API_INHALE_PATH } from "./apiClient"; import type { SigilSharePayloadLoose } from "./types"; -import { canonicalizeUrl, extractPayloadFromUrl, isPTildeUrl, looksLikeBareToken, parseStreamToken, streamUrlFromToken } from "./url"; +import { + canonicalizeUrl, + extractPayloadFromUrl, + isPTildeUrl, + looksLikeBareToken, + parseStreamToken, + streamUrlFromToken, +} from "./url"; import { memoryRegistry, isOnline } from "./registryStore"; const hasWindow = typeof window !== "undefined"; +const canStorage = + hasWindow && + (() => { + try { + return typeof window.localStorage !== "undefined"; + } catch { + return false; + } + })(); const INHALE_BATCH_MAX = 200; const INHALE_BATCH_MAX_BYTES = 220_000; @@ -24,6 +40,12 @@ let inhaleRetryMs = 0; const canMatchMedia = hasWindow && typeof window.matchMedia === "function"; const isCoarsePointer = canMatchMedia && window.matchMedia("(pointer: coarse)").matches; +function isVisible(): boolean { + if (!hasWindow) return false; + if (typeof document === "undefined") return true; + return document.visibilityState === "visible"; +} + function shouldFastFlush(): boolean { if (!hasWindow) return false; if (!isCoarsePointer) return false; @@ -41,6 +63,24 @@ function scheduleFastFlush(): void { } } +// ✅ Wake flush when the tab becomes visible or returns online (mobile-safe) +if (hasWindow) { + try { + window.addEventListener("online", () => scheduleFastFlush()); + } catch { + // ignore + } + if (typeof document !== "undefined") { + try { + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") scheduleFastFlush(); + }); + } catch { + // ignore + } + } +} + function randId(): string { if (hasWindow && typeof window.crypto?.randomUUID === "function") { return window.crypto.randomUUID(); @@ -53,19 +93,25 @@ function isRecord(v: unknown): v is Record { } function saveInhaleQueueToStorage(): void { - if (!hasWindow) return; + if (!canStorage) return; try { const json = JSON.stringify([...inhaleQueue.entries()]); localStorage.setItem(INHALE_QUEUE_LS_KEY, json); } catch { - // ignore quota issues + // ignore quota/private-mode issues } } function loadInhaleQueueFromStorage(): void { - if (!hasWindow) return; - const raw = localStorage.getItem(INHALE_QUEUE_LS_KEY); + if (!canStorage) return; + let raw: string | null = null; + try { + raw = localStorage.getItem(INHALE_QUEUE_LS_KEY); + } catch { + return; + } if (!raw) return; + try { const arr = JSON.parse(raw) as unknown; if (!Array.isArray(arr)) return; @@ -95,6 +141,7 @@ function enqueueInhaleRawKrystal(krystal: Record): void { inhaleFlushTimer = null; void flushInhaleQueue(); }, INHALE_DEBOUNCE_MS); + scheduleFastFlush(); } @@ -111,6 +158,7 @@ function enqueueInhaleKrystal(url: string, payload: SigilSharePayloadLoose): voi inhaleFlushTimer = null; void flushInhaleQueue(); }, INHALE_DEBOUNCE_MS); + scheduleFastFlush(); } @@ -211,6 +259,10 @@ function forceInhaleUrls(urls: readonly string[]): void { async function flushInhaleQueue(): Promise { if (!hasWindow) return; + + // ✅ Don’t run flush work in background tabs (iOS stability) + if (!isVisible()) return; + if (!isOnline()) return; if (inhaleInFlight) return; if (inhaleQueue.size === 0) return; @@ -221,16 +273,13 @@ async function flushInhaleQueue(): Promise { const batch: Record[] = []; const keys: string[] = []; const encoder = typeof TextEncoder !== "undefined" ? new TextEncoder() : null; - let currentBytes = 2; let droppedOversize = false; for (const [k, v] of inhaleQueue) { - const next = [...batch, v]; + const next = batch.length === 0 ? [v] : [...batch, v]; const jsonPreview = JSON.stringify(next); const size = - encoder != null - ? encoder.encode(jsonPreview).byteLength - : new Blob([jsonPreview]).size; + encoder != null ? encoder.encode(jsonPreview).byteLength : new Blob([jsonPreview]).size; if (batch.length === 0 && size > INHALE_BATCH_MAX_BYTES) { inhaleQueue.delete(k); @@ -238,25 +287,19 @@ async function flushInhaleQueue(): Promise { continue; } - if ( - batch.length > 0 && - (batch.length >= INHALE_BATCH_MAX || size > INHALE_BATCH_MAX_BYTES) - ) { + if (batch.length > 0 && (batch.length >= INHALE_BATCH_MAX || size > INHALE_BATCH_MAX_BYTES)) { break; } batch.push(v); keys.push(k); - currentBytes = size; - if (batch.length >= INHALE_BATCH_MAX || currentBytes >= INHALE_BATCH_MAX_BYTES) { + if (batch.length >= INHALE_BATCH_MAX || size >= INHALE_BATCH_MAX_BYTES) { break; } } - if (droppedOversize) { - saveInhaleQueueToStorage(); - } + if (droppedOversize) saveInhaleQueueToStorage(); if (batch.length === 0) { inhaleRetryMs = 0; @@ -291,9 +334,9 @@ async function flushInhaleQueue(): Promise { const res = await apiFetchWithFailover(makeUrl, { method: "POST", body: fd }); if (!res || !res.ok) throw new Error(`inhale failed: ${res?.status ?? 0}`); + // best-effort parse; not required try { - const _parsed = (await res.json()) as unknown; - void _parsed; + void (await res.json()); } catch { // ignore } @@ -309,7 +352,17 @@ async function flushInhaleQueue(): Promise { }, 10); } } catch { - inhaleRetryMs = Math.min(inhaleRetryMs ? inhaleRetryMs * 2 : INHALE_RETRY_BASE_MS, INHALE_RETRY_MAX_MS); + // ✅ If offline/hidden, don’t spin retries—listeners will wake us. + if (!isOnline() || !isVisible()) { + inhaleRetryMs = 0; + return; + } + + inhaleRetryMs = Math.min( + inhaleRetryMs ? inhaleRetryMs * 2 : INHALE_RETRY_BASE_MS, + INHALE_RETRY_MAX_MS, + ); + inhaleFlushTimer = window.setTimeout(() => { inhaleFlushTimer = null; void flushInhaleQueue(); diff --git a/src/components/SigilExplorer/registryStore.ts b/src/components/SigilExplorer/registryStore.ts index f5d32c14f..93274166e 100644 --- a/src/components/SigilExplorer/registryStore.ts +++ b/src/components/SigilExplorer/registryStore.ts @@ -6,7 +6,11 @@ import type { Registry, SigilSharePayloadLoose } from "./types"; import { USERNAME_CLAIM_KIND, type UsernameClaimPayload } from "../../types/usernameClaim"; import { ingestUsernameClaimGlyph } from "../../utils/usernameClaimRegistry"; import { normalizeClaimGlyphRef, normalizeUsername } from "../../utils/usernameClaim"; -import { makeSigilUrlLoose, resolveLineageBackwards, type SigilSharePayloadLoose as SigilUrlPayloadLoose } from "../../utils/sigilUrl"; +import { + makeSigilUrlLoose, + resolveLineageBackwards, + type SigilSharePayloadLoose as SigilUrlPayloadLoose, +} from "../../utils/sigilUrl"; import { getInMemorySigilUrls } from "../../utils/sigilRegistry"; import { markConfirmedByNonce } from "../../utils/sendLedger"; import { @@ -25,6 +29,10 @@ export const MODAL_FALLBACK_LS_KEY = "sigil:urls"; // composer/modal fallback UR export const NOTE_CLAIM_LS_KEY = "kai:sigil-claims:v1"; // persistent note-claim registry const BC_NAME = "kai-sigil-registry"; +// ✅ mobile-safe invalidation channels (same-tab + cross-tab) +const EVT_REGISTRY = "kai:sigil-registry:v1:changed"; +const LS_REGISTRY_BUMP = "kai:sigil-registry:v1:bump"; + const WITNESS_ADD_MAX = 512; const hasWindow = typeof window !== "undefined"; @@ -33,7 +41,16 @@ let registryHydrated = false; let noteClaimsHydrated = false; export const memoryRegistry: Registry = new Map(); -const noteClaimRegistry: Map> = new Map(); + +export type NoteClaimRecord = { + nonce: string; + claimedPulse: number; + childCanonical?: string; + transferLeafHash?: string; +}; + +type NoteClaimMap = Map; +const noteClaimRegistry: Map = new Map(); const channel = hasWindow && "BroadcastChannel" in window ? new BroadcastChannel(BC_NAME) : null; export type AddSource = "local" | "remote" | "hydrate" | "import"; @@ -50,14 +67,102 @@ type NoteClaimArgs = { parentCanonical: string; transferNonce: string; childCanonical?: string; + claimedPulse?: number; }; +type RegistryEvent = + | { type: "sigil:add"; url: string } + | { type: "note:claim"; parentCanonical: string; transferNonce: string }; + export function isOnline(): boolean { if (!hasWindow) return false; if (typeof navigator === "undefined") return true; return navigator.onLine; } +/* ──────────────────────────────────────────────────────────────── + ✅ Mobile-safe registry invalidation +─────────────────────────────────────────────────────────────── */ + +/** + * Emit an invalidation event that works: + * - same-tab: CustomEvent (works everywhere, including iOS WKWebView) + * - cross-tab: localStorage bump (storage event), even if BroadcastChannel is missing/flaky + */ +function emitRegistryLocal(evt: RegistryEvent): void { + if (!hasWindow) return; + + // Same-tab signal + try { + window.dispatchEvent(new CustomEvent(EVT_REGISTRY, { detail: evt })); + } catch { + /* ignore */ + } + + // Cross-tab signal (storage event fires in other tabs) + try { + if (canStorage) localStorage.setItem(LS_REGISTRY_BUMP, String(Date.now())); + } catch { + /* ignore */ + } +} + +/** Fire-and-forget registry notification. */ +function safeRegistryPost(evt: RegistryEvent): void { + try { + channel?.postMessage(evt); + } catch { + /* ignore */ + } + emitRegistryLocal(evt); +} + +/** + * Coarse listener for registry changes (URLs or note-claims). + * Use this in pages (VerifyPage) to re-render when the registry changes. + */ +export function listenRegistry(cb: () => void): () => void { + if (!hasWindow) return () => {}; + + let scheduled = false; + const fire = () => { + if (scheduled) return; + scheduled = true; + queueMicrotask(() => { + scheduled = false; + cb(); + }); + }; + + const unsubs: Array<() => void> = []; + + // BroadcastChannel (if available) + if (channel) { + const onMsg = () => fire(); + channel.addEventListener("message", onMsg as EventListener); + unsubs.push(() => channel.removeEventListener("message", onMsg as EventListener)); + } + + // Same-tab CustomEvent (always) + const onEvt = () => fire(); + window.addEventListener(EVT_REGISTRY, onEvt as EventListener); + unsubs.push(() => window.removeEventListener(EVT_REGISTRY, onEvt as EventListener)); + + // Cross-tab storage fallback + const onStorage = (e: StorageEvent) => { + const k = e.key || ""; + if (k === REGISTRY_LS_KEY || k === NOTE_CLAIM_LS_KEY || k === LS_REGISTRY_BUMP) fire(); + }; + window.addEventListener("storage", onStorage); + unsubs.push(() => window.removeEventListener("storage", onStorage)); + + return () => unsubs.forEach((fn) => fn()); +} + +/* ──────────────────────────────────────────────────────────────── + Helpers +─────────────────────────────────────────────────────────────── */ + function isRecord(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); } @@ -70,6 +175,17 @@ function readStringField(obj: unknown, key: string): string | undefined { return t ? t : undefined; } +function readNumberField(obj: unknown, key: string): number | undefined { + if (!isRecord(obj)) return undefined; + const v = obj[key]; + if (typeof v === "number" && Number.isFinite(v)) return v; + if (typeof v === "string") { + const n = Number(v); + if (Number.isFinite(n)) return n; + } + return undefined; +} + function readTransferDirectionValue(value: unknown): "send" | "receive" | null { if (typeof value !== "string") return null; const t = value.trim().toLowerCase(); @@ -98,9 +214,10 @@ function normalizeNonce(raw: string | undefined | null): string { } function buildNoteClaimPayload(args: NoteClaimArgs): SigilUrlPayloadLoose { - const { parentCanonical, transferNonce, childCanonical } = args; + const { parentCanonical, transferNonce, childCanonical, claimedPulse } = args; + const claimedPulseValue = Number.isFinite(claimedPulse ?? NaN) ? Number(claimedPulse) : 0; const payload: SigilUrlPayloadLoose = { - pulse: 0, + pulse: claimedPulseValue, beat: 0, stepIndex: 0, chakraDay: "Root", @@ -108,6 +225,7 @@ function buildNoteClaimPayload(args: NoteClaimArgs): SigilUrlPayloadLoose { transferNonce, parentCanonical, }; + if (claimedPulseValue > 0) payload.claimedPulse = claimedPulseValue; if (childCanonical) { payload.canonicalHash = childCanonical; payload.childHash = childCanonical; @@ -133,17 +251,49 @@ function hydrateNoteClaimsFromStorage(): boolean { if (!Array.isArray(value)) continue; const parentKey = normalizeCanonical(parent); if (!parentKey) continue; - const set = noteClaimRegistry.get(parentKey) ?? new Set(); + const map = noteClaimRegistry.get(parentKey) ?? new Map(); for (const entry of value) { - if (typeof entry !== "string") continue; - const nonce = normalizeNonce(entry); + if (typeof entry === "string") { + const nonce = normalizeNonce(entry); + if (!nonce || map.has(nonce)) continue; + map.set(nonce, { nonce, claimedPulse: 0 }); + changed = true; + continue; + } + if (!isRecord(entry)) continue; + const nonce = normalizeNonce(readStringField(entry, "nonce")); if (!nonce) continue; - if (!set.has(nonce)) { - set.add(nonce); + const claimedPulse = Number(entry.claimedPulse ?? entry.claimedAt ?? 0); + const childCanonical = normalizeCanonical(readStringField(entry, "childCanonical")); + const transferLeafHash = readStringField(entry, "transferLeafHash") ?? undefined; + const existing = map.get(nonce); + const next: NoteClaimRecord = { + nonce, + claimedPulse: Number.isFinite(claimedPulse) ? claimedPulse : 0, + childCanonical: childCanonical || undefined, + transferLeafHash, + }; + if (!existing) { + map.set(nonce, next); changed = true; + } else { + const merged: NoteClaimRecord = { + nonce, + claimedPulse: existing.claimedPulse || next.claimedPulse, + childCanonical: existing.childCanonical || next.childCanonical, + transferLeafHash: existing.transferLeafHash || next.transferLeafHash, + }; + if ( + merged.claimedPulse !== existing.claimedPulse || + merged.childCanonical !== existing.childCanonical || + merged.transferLeafHash !== existing.transferLeafHash + ) { + map.set(nonce, merged); + changed = true; + } } } - if (set.size > 0) noteClaimRegistry.set(parentKey, set); + if (map.size > 0) noteClaimRegistry.set(parentKey, map); } return changed; } catch { @@ -154,9 +304,9 @@ function hydrateNoteClaimsFromStorage(): boolean { function persistNoteClaimsToStorage(): void { if (!canStorage) return; try { - const obj: Record = {}; - for (const [parent, nonces] of noteClaimRegistry.entries()) { - const list = Array.from(nonces.values()); + const obj: Record = {}; + for (const [parent, claims] of noteClaimRegistry.entries()) { + const list = Array.from(claims.values()); if (list.length > 0) obj[parent] = list; } localStorage.setItem(NOTE_CLAIM_LS_KEY, JSON.stringify(obj)); @@ -170,36 +320,94 @@ function ensureNoteClaimsHydrated(): void { noteClaimsHydrated = true; hydrateNoteClaimsFromStorage(); } - export function markNoteClaimed( parentCanonical: string, transferNonce: string, - args?: { childCanonical?: string }, + args?: { childCanonical?: string; claimedPulse?: number; transferLeafHash?: string }, ): boolean { + // ✅ hydrate both claims + registry before we operate ensureNoteClaimsHydrated(); + ensureRegistryHydrated?.(); // keep if you have it; safe no-op otherwise + const parentKey = normalizeCanonical(parentCanonical); const nonce = normalizeNonce(transferNonce); if (!parentKey || !nonce) return false; - const set = noteClaimRegistry.get(parentKey) ?? new Set(); - if (set.has(nonce)) return false; - set.add(nonce); - noteClaimRegistry.set(parentKey, set); - persistNoteClaimsToStorage(); + + const claimedPulse = + typeof args?.claimedPulse === "number" && Number.isFinite(args.claimedPulse) ? args.claimedPulse : 0; + + const childCanonical = normalizeCanonical(args?.childCanonical); + const transferLeafHash = typeof args?.transferLeafHash === "string" ? args.transferLeafHash : undefined; + + // ───────────────────────────────────────────────────────────── + // 1) Update claim registry (only if changed) + // ───────────────────────────────────────────────────────────── + const map = noteClaimRegistry.get(parentKey) ?? new Map(); + const existing = map.get(nonce); + + const next: NoteClaimRecord = { + nonce, + // keep the first non-zero pulse we ever saw, else take new pulse + claimedPulse: (existing?.claimedPulse ?? 0) > 0 ? (existing!.claimedPulse as number) : claimedPulse, + childCanonical: existing?.childCanonical || childCanonical, + transferLeafHash: existing?.transferLeafHash || transferLeafHash, + }; + + const claimChanged = + !existing || + next.claimedPulse !== existing.claimedPulse || + next.childCanonical !== existing.childCanonical || + next.transferLeafHash !== existing.transferLeafHash; + + if (claimChanged) { + map.set(nonce, next); + noteClaimRegistry.set(parentKey, map); + persistNoteClaimsToStorage(); + } + + // ───────────────────────────────────────────────────────────── + // 2) ALWAYS materialize + persist the claim URL into the global registry + // (this is the “infinite generations” fix) + // ───────────────────────────────────────────────────────────── const claimPayload = buildNoteClaimPayload({ parentCanonical: parentKey, transferNonce: nonce, - childCanonical: args?.childCanonical, + childCanonical: childCanonical || undefined, + claimedPulse: next.claimedPulse || claimedPulse || undefined, }); + const claimUrl = buildNoteClaimUrl({ parentCanonical: parentKey, transferNonce: nonce, - childCanonical: args?.childCanonical, + childCanonical: childCanonical || undefined, + claimedPulse: next.claimedPulse || claimedPulse || undefined, }); + + // These are idempotent; call every time so registry repairs itself after cache clears. upsertRegistryPayload(claimUrl, claimPayload); enqueueInhaleKrystal(claimUrl, claimPayload); - return true; + + // ✅ persist registry list EVERY TIME + persistRegistryToStorage(); + + // Optional: keep modal fallback aligned too + if (canStorage) { + try { + const urls = Array.from(memoryRegistry.keys()); + localStorage.setItem(MODAL_FALLBACK_LS_KEY, JSON.stringify(urls)); + } catch { + /* ignore */ + } + } + + safeRegistryPost({ type: "note:claim", parentCanonical: parentKey, transferNonce: nonce }); + + // return “did something” + return claimChanged; } + + export function isNoteClaimed(parentCanonical: string, transferNonce: string): boolean { ensureNoteClaimsHydrated(); const parentKey = normalizeCanonical(parentCanonical); @@ -208,6 +416,33 @@ export function isNoteClaimed(parentCanonical: string, transferNonce: string): b return noteClaimRegistry.get(parentKey)?.has(nonce) ?? false; } +export function getNoteClaimInfo(parentCanonical: string, transferNonce: string): NoteClaimRecord | null { + ensureNoteClaimsHydrated(); + const parentKey = normalizeCanonical(parentCanonical); + const nonce = normalizeNonce(transferNonce); + if (!parentKey || !nonce) return null; + return noteClaimRegistry.get(parentKey)?.get(nonce) ?? null; +} + +export function getNoteClaimHistory(parentCanonical: string): NoteClaimRecord[] { + ensureNoteClaimsHydrated(); + const parentKey = normalizeCanonical(parentCanonical); + if (!parentKey) return []; + const map = noteClaimRegistry.get(parentKey); + if (!map) return []; + return Array.from(map.values()).sort((a, b) => { + const at = a.claimedPulse || 0; + const bt = b.claimedPulse || 0; + if (at !== bt) return bt - at; + return a.nonce.localeCompare(b.nonce); + }); +} + +export function getNoteClaimLeader(parentCanonical: string): NoteClaimRecord | null { + const history = getNoteClaimHistory(parentCanonical); + return history[0] ?? null; +} + function safeDecodeURIComponent(v: string): string { try { return decodeURIComponent(v); @@ -521,10 +756,26 @@ export function addUrl(url: string, opts?: AddUrlOptions): boolean { const parentHash = readStringField(record, "parentHash") ?? readStringField(record, "parentCanonical"); const nonce = readStringField(record, "transferNonce") ?? readStringField(record, "nonce"); const childCanonical = - readStringField(record, "canonicalHash") ?? readStringField(record, "childHash") ?? readStringField(record, "hash"); + readStringField(record, "canonicalHash") ?? + readStringField(record, "childHash") ?? + readStringField(record, "hash"); + const transferLeafHash = + readStringField(record, "transferLeafHashSend") ?? + readStringField(record, "transferLeafHashReceive") ?? + readStringField(record, "leafHash"); + const claimedPulse = + readNumberField(record, "claimedPulse") ?? + readNumberField(record, "receivePulse") ?? + readNumberField(record, "pulse") ?? + 0; + if (parentHash && nonce) { markConfirmedByNonce(parentHash, nonce); - markNoteClaimed(parentHash, nonce, { childCanonical: childCanonical ?? undefined }); + markNoteClaimed(parentHash, nonce, { + childCanonical: childCanonical ?? undefined, + transferLeafHash: transferLeafHash ?? undefined, + claimedPulse, + }); } } @@ -547,7 +798,9 @@ export function addUrl(url: string, opts?: AddUrlOptions): boolean { if (changed) { if (persist) persistRegistryToStorage(); - if (channel && broadcast) channel.postMessage({ type: "sigil:add", url: abs }); + + // ✅ broadcast + mobile-safe invalidation + if (broadcast) safeRegistryPost({ type: "sigil:add", url: abs }); if (enqueueToApi) { const latest = memoryRegistry.get(abs); diff --git a/src/components/VerifierStamper/VerifierStamper.css b/src/components/VerifierStamper/VerifierStamper.css index b56f5be6e..83728c4a1 100644 --- a/src/components/VerifierStamper/VerifierStamper.css +++ b/src/components/VerifierStamper/VerifierStamper.css @@ -418,7 +418,25 @@ dialog.glass-modal.fullscreen[data-open="true"] { max-width: 100%; overflow: hidden; } +@media (max-width: 720px) { + dialog.glass-modal.fullscreen { + inline-size: 100vw; + max-inline-size: 100vw; + block-size: 100vh; + max-height: 100vh; + margin: 0; + border-radius: 0; + } + @supports (width: 100dvw) { + dialog.glass-modal.fullscreen { + inline-size: 100dvw; + max-inline-size: 100dvw; + block-size: 100dvh; + max-height: 100dvh; + } + } +} /* ─────────────────────────────────────────────────────────────── 5a) Modal topbar + close button ──────────────────────────────────────────────────────────────── */ diff --git a/src/components/VerifierStamper/VerifierStamper.tsx b/src/components/VerifierStamper/VerifierStamper.tsx index bac9265be..1bd67bed2 100644 --- a/src/components/VerifierStamper/VerifierStamper.tsx +++ b/src/components/VerifierStamper/VerifierStamper.tsx @@ -99,7 +99,12 @@ import { embedProofMetadata } from "../../utils/svgProof"; import { extractProofBundleMetaFromSvg, type ProofBundleMeta } from "../../utils/sigilMetadata"; import { DEFAULT_ISSUANCE_POLICY, quotePhiForUsd } from "../../utils/phi-issuance"; import { BREATH_MS } from "../valuation/constants"; -import { recordSend, getReservedScaledFor, markConfirmedByLeaf } from "../../utils/sendLedger"; +import { + recordSend, + getPendingReservedScaledForKind, + getReservedScaledForKind, + markConfirmedByLeaf, +} from "../../utils/sendLedger"; import { recordSigilTransferMovement } from "../../utils/sigilTransferRegistry"; import { buildBundleRoot, @@ -646,28 +651,115 @@ const VerifierStamperInner: React.FC = () => { dlgRef.current?.setAttribute("data-open", "false"); }; - const noteInitial = useMemo( - () => { +const noteInitial = useMemo(() => { + const base = buildNotePayload({ + meta, + sigilSvgRaw, + verifyUrl: sealUrl || (typeof window !== "undefined" ? window.location.href : ""), + pulseNow, + }); + + // ✅ Prefer the live parsed proof bundle; fallback: extract directly from the raw SVG text + const extracted = sigilSvgRaw && sigilSvgRaw.trim() ? extractProofBundleMetaFromSvg(sigilSvgRaw) : null; + const bundleMeta = proofBundleMeta?.raw ? proofBundleMeta : extracted; + + const rawBundle = bundleMeta?.raw; + const rawRecord = isRecord(rawBundle) ? rawBundle : null; + + const proofBundleJson = rawRecord ? JSON.stringify(rawRecord) : ""; + + const bundleHash = + bundleMeta?.bundleHash ?? + (rawRecord && typeof rawRecord.bundleHash === "string" ? (rawRecord.bundleHash as string) : ""); + + const receiptHash = + (bundleMeta as { receiptHash?: string } | null)?.receiptHash ?? + (rawRecord && typeof rawRecord.receiptHash === "string" ? (rawRecord.receiptHash as string) : ""); + + const verifiedAtPulse = + typeof bundleMeta?.verifiedAtPulse === "number" + ? bundleMeta.verifiedAtPulse + : rawRecord && typeof rawRecord.verifiedAtPulse === "number" + ? (rawRecord.verifiedAtPulse as number) + : undefined; + + const capsuleHash = + bundleMeta?.capsuleHash ?? + (rawRecord && typeof rawRecord.capsuleHash === "string" ? (rawRecord.capsuleHash as string) : ""); + + const svgHash = + bundleMeta?.svgHash ?? + (rawRecord && typeof rawRecord.svgHash === "string" ? (rawRecord.svgHash as string) : ""); + + return { + ...base, + proofBundleJson, + bundleHash, + receiptHash, + verifiedAtPulse, + capsuleHash, + svgHash, + }; +}, [meta, sigilSvgRaw, sealUrl, pulseNow, proofBundleMeta]); + + +const openNote = () => + switchModal(dlgRef.current, () => { + const d = noteDlgRef.current; + if (!d) return; + + // ✅ Build note data at call-time (avoids first-click race on state) + const buildNoteData = async (): Promise => { + let svgRaw = (sigilSvgRaw ?? "").trim(); + + // If state hasn't landed yet, fetch raw SVG from the blob URL (first-click safe) + if (!svgRaw && svgURL) { + try { + svgRaw = (await fetch(svgURL).then((r) => r.text())).trim(); + } catch (err) { + logError("openNote.fetch(svgURL)", err); + svgRaw = ""; + } + } + const base = buildNotePayload({ meta, - sigilSvgRaw, + sigilSvgRaw: svgRaw || null, verifyUrl: sealUrl || (typeof window !== "undefined" ? window.location.href : ""), pulseNow, }); - const rawBundle = proofBundleMeta?.raw; + // ✅ Prefer proofBundleMeta; fallback: extract directly from the SVG text + const extracted = svgRaw ? extractProofBundleMetaFromSvg(svgRaw) : null; + const bundleMeta = proofBundleMeta?.raw ? proofBundleMeta : extracted; + + const rawBundle = bundleMeta?.raw; const rawRecord = isRecord(rawBundle) ? rawBundle : null; + const proofBundleJson = rawRecord ? JSON.stringify(rawRecord) : ""; - const bundleHash = proofBundleMeta?.bundleHash ?? (rawRecord && typeof rawRecord.bundleHash === "string" ? rawRecord.bundleHash : ""); - const receiptHash = proofBundleMeta?.receiptHash ?? (rawRecord && typeof rawRecord.receiptHash === "string" ? rawRecord.receiptHash : ""); + + const bundleHash = + bundleMeta?.bundleHash ?? + (rawRecord && typeof rawRecord.bundleHash === "string" ? (rawRecord.bundleHash as string) : ""); + + const receiptHash = + (bundleMeta as { receiptHash?: string } | null)?.receiptHash ?? + (rawRecord && typeof rawRecord.receiptHash === "string" ? (rawRecord.receiptHash as string) : ""); + const verifiedAtPulse = - typeof proofBundleMeta?.verifiedAtPulse === "number" - ? proofBundleMeta.verifiedAtPulse + typeof bundleMeta?.verifiedAtPulse === "number" + ? bundleMeta.verifiedAtPulse : rawRecord && typeof rawRecord.verifiedAtPulse === "number" - ? rawRecord.verifiedAtPulse + ? (rawRecord.verifiedAtPulse as number) : undefined; - const capsuleHash = proofBundleMeta?.capsuleHash ?? (rawRecord && typeof rawRecord.capsuleHash === "string" ? rawRecord.capsuleHash : ""); - const svgHash = proofBundleMeta?.svgHash ?? (rawRecord && typeof rawRecord.svgHash === "string" ? rawRecord.svgHash : ""); + + const capsuleHash = + bundleMeta?.capsuleHash ?? + (rawRecord && typeof rawRecord.capsuleHash === "string" ? (rawRecord.capsuleHash as string) : ""); + + const svgHash = + bundleMeta?.svgHash ?? + (rawRecord && typeof rawRecord.svgHash === "string" ? (rawRecord.svgHash as string) : ""); return { ...base, @@ -678,52 +770,24 @@ const VerifierStamperInner: React.FC = () => { capsuleHash, svgHash, }; - }, - [meta, sigilSvgRaw, sealUrl, pulseNow, proofBundleMeta] - ); + }; - const openNote = () => - switchModal(dlgRef.current, () => { - const d = noteDlgRef.current; - if (!d) return; - const base = buildNotePayload({ - meta, - sigilSvgRaw, - verifyUrl: sealUrl || (typeof window !== "undefined" ? window.location.href : ""), - pulseNow, - }); - const rawBundle = proofBundleMeta?.raw; - const rawRecord = isRecord(rawBundle) ? rawBundle : null; - const proofBundleJson = rawRecord ? JSON.stringify(rawRecord) : ""; - const bundleHash = proofBundleMeta?.bundleHash ?? (rawRecord && typeof rawRecord.bundleHash === "string" ? rawRecord.bundleHash : ""); - const receiptHash = proofBundleMeta?.receiptHash ?? (rawRecord && typeof rawRecord.receiptHash === "string" ? rawRecord.receiptHash : ""); - const verifiedAtPulse = - typeof proofBundleMeta?.verifiedAtPulse === "number" - ? proofBundleMeta.verifiedAtPulse - : rawRecord && typeof rawRecord.verifiedAtPulse === "number" - ? rawRecord.verifiedAtPulse - : undefined; - const capsuleHash = proofBundleMeta?.capsuleHash ?? (rawRecord && typeof rawRecord.capsuleHash === "string" ? rawRecord.capsuleHash : ""); - const svgHash = proofBundleMeta?.svgHash ?? (rawRecord && typeof rawRecord.svgHash === "string" ? rawRecord.svgHash : ""); - const p = { - ...base, - proofBundleJson, - bundleHash, - receiptHash, - verifiedAtPulse, - capsuleHash, - svgHash, - }; - const bridge: VerifierBridge = { getNoteData: async () => p }; - setVerifierBridge(bridge); + const bridge: VerifierBridge = { getNoteData: buildNoteData }; + setVerifierBridge(bridge); + + // Push immediate hydration event (best effort); ExhaleNote also pulls from bridge on mount + void (async () => { try { + const p = await buildNoteData(); window.dispatchEvent(new CustomEvent("kk:note-data", { detail: p })); } catch (err) { logError("dispatch(kk:note-data)", err); } - safeShowDialog(d); - setNoteOpen(true); - }); + })(); + + safeShowDialog(d); + setNoteOpen(true); + }); const closeNote = () => { const d = noteDlgRef.current; @@ -1979,19 +2043,27 @@ const VerifierStamperInner: React.FC = () => { return toScaledBig(String(initialGlyph?.value ?? 0) || "0"); }, [isChildContext, meta, lastTransfer, persistedBaseScaled, pivotIndex, initialGlyph]); + const branchSpentScaled = useMemo( + () => toScaledBig((meta as SigilMetadataWithOptionals | null)?.branchSpentPhi ?? "0"), + [meta] + ); + const ledgerReservedScaled = useMemo(() => { if (!canonical) return 0n; try { - return getReservedScaledFor(canonical); + if (branchSpentScaled > 0n) { + return getReservedScaledForKind(canonical, "note") + getPendingReservedScaledForKind(canonical, "send"); + } + return getReservedScaledForKind(canonical, "all"); } catch (err) { logError("ledgerReservedScaled", err); return 0n; } - }, [canonical]); + }, [canonical, branchSpentScaled]); const totalSpentScaled = useMemo( - () => (isChildContext ? 0n : ledgerReservedScaled), - [isChildContext, ledgerReservedScaled] + () => (isChildContext ? 0n : branchSpentScaled + ledgerReservedScaled), + [isChildContext, branchSpentScaled, ledgerReservedScaled] ); const remainingPhiScaled = useMemo( @@ -2010,8 +2082,32 @@ const VerifierStamperInner: React.FC = () => { if (noteSendBusyRef.current) return; noteSendBusyRef.current = true; try { - const parentCanonical = (canonical ?? "").toLowerCase(); - if (!parentCanonical) throw new Error("Origin sigil not initialized."); +// ✅ Prefer existing computed canonical; fallback to deterministic derivation from meta +let parentCanonical = (canonical ?? "").toLowerCase().trim(); + +if (!parentCanonical && meta) { + const direct = (meta.canonicalHash as string | undefined)?.toLowerCase().trim(); + if (direct) parentCanonical = direct; +} + +if (!parentCanonical && meta) { + try { + const eff = await computeEffectiveCanonical(meta); + if (eff?.canonical) parentCanonical = eff.canonical.toLowerCase().trim(); + } catch (err) { + logError("noteSend.computeEffectiveCanonical", err); + } +} + +if (!parentCanonical && meta) { + try { + parentCanonical = (await sha256Hex(`${meta.pulse}|${meta.beat}|${meta.stepIndex}|${meta.chakraDay}`)).toLowerCase(); + } catch (err) { + logError("noteSend.sha256Fallback", err); + } +} + +if (!parentCanonical) throw new Error("Origin sigil not initialized."); const amountScaled = toScaledBig(String(payload.amountPhi || 0)); if (amountScaled <= 0n) throw new Error("Invalid Φ amount."); @@ -2051,6 +2147,7 @@ const VerifierStamperInner: React.FC = () => { parentCanonical, childCanonical, amountPhiScaled: amountScaled.toString(), + kind: "note" as const, senderKaiPulse, transferNonce, senderStamp, @@ -2088,9 +2185,9 @@ const VerifierStamperInner: React.FC = () => { } finally { noteSendBusyRef.current = false; } - }, - [canonical, meta, remainingPhiScaled, remainingPhiDisplay4], - ); +}, [canonical, meta, computeEffectiveCanonical, remainingPhiScaled, remainingPhiDisplay4], +); + // Snap headline Φ to 6dp for UI (math stays BigInt elsewhere) const headerPhi = useMemo(() => snap6(Number(fromScaledBig(remainingPhiScaled))), [remainingPhiScaled]); @@ -2511,6 +2608,7 @@ const VerifierStamperInner: React.FC = () => { parentCanonical, childCanonical, amountPhiScaled: toScaledBig(validPhi6).toString(), // μΦ-exact + kind: "send" as const, senderKaiPulse: nowPulse, transferNonce: updated.transferNonce!, senderStamp: stamp, diff --git a/src/pages/SigilPage/SigilPage.tsx b/src/pages/SigilPage/SigilPage.tsx index ce77a899c..8b03b561b 100644 --- a/src/pages/SigilPage/SigilPage.tsx +++ b/src/pages/SigilPage/SigilPage.tsx @@ -138,6 +138,8 @@ import { currentCanonical as currentCanonicalUtil, currentToken as currentTokenUtil, } from "../../utils/urlShort"; +import { getPendingReservedScaledForKind, getReservedScaledForKind } from "../../utils/sendLedger"; +import { fromScaledBig, toScaledBig } from "../../components/verifier/utils/decimal"; // registry.ts import { buildClaim, @@ -1994,17 +1996,44 @@ setTimeout(() => setSuppressAuthUntil(0), 0); return sumDebits(items); }, [payloadD?.debits]); + const branchSpentScaled = useMemo( + () => toScaledBig(String((payloadD as { branchSpentPhi?: string | number } | null)?.branchSpentPhi ?? "0")), + [payloadD] + ); + + const branchSpentPhi = useMemo( + () => (branchSpentScaled > 0n ? Number(fromScaledBig(branchSpentScaled)) : 0), + [branchSpentScaled] + ); + + const ledgerReservedScaled = useMemo(() => { + const h = currentCanonicalUtil(payload ?? null, localHash, legacyInfo); + if (!h) return 0n; + if (branchSpentScaled > 0n) { + return getReservedScaledForKind(h, "note") + getPendingReservedScaledForKind(h, "send"); + } + return getReservedScaledForKind(h, "all"); + }, [payload, localHash, legacyInfo, branchSpentScaled]); + + const ledgerReservedPhi = useMemo( + () => (ledgerReservedScaled > 0n ? Number(fromScaledBig(ledgerReservedScaled)) : 0), + [ledgerReservedScaled] + ); + const availablePhi = useMemo(() => { const base = typeof payloadD?.originalAmount === "number" ? payloadD.originalAmount : (valSeal?.valuePhi ?? 0); - const avail = base - totalDebited; + const avail = base - totalDebited - branchSpentPhi - ledgerReservedPhi; return avail > 0 ? avail : 0; - }, [payloadD?.originalAmount, valSeal?.valuePhi, totalDebited]); + }, [payloadD?.originalAmount, valSeal?.valuePhi, totalDebited, branchSpentPhi, ledgerReservedPhi]); const hasDebitsOrFrozen = - (payloadD?.debits?.length ?? 0) > 0 || typeof payloadD?.originalAmount === "number"; + (payloadD?.debits?.length ?? 0) > 0 || + typeof payloadD?.originalAmount === "number" || + branchSpentPhi > 0 || + ledgerReservedPhi > 0; const displayedChipPhi = useMemo(() => { if (hasDebitsOrFrozen) return availablePhi; diff --git a/src/pages/SigilPage/useSigilSend.ts b/src/pages/SigilPage/useSigilSend.ts index 6ec0ed05b..3d6779fa7 100644 --- a/src/pages/SigilPage/useSigilSend.ts +++ b/src/pages/SigilPage/useSigilSend.ts @@ -9,6 +9,8 @@ import { type DebitRecord, type DebitQS, } from "../../utils/cryptoLedger"; +import { getPendingReservedScaledForKind, getReservedScaledForKind } from "../../utils/sendLedger"; +import { fromScaledBig, toScaledBig } from "../../components/verifier/utils/decimal"; import { currentCanonical as currentCanonicalUtil, currentToken as currentTokenUtil, @@ -309,9 +311,22 @@ export function useSigilSend(params: { debits: Array.isArray(merged.debits) ? merged.debits : [], }); + const branchSpentScaled = toScaledBig( + String((withDebits as { branchSpentPhi?: string | number } | null)?.branchSpentPhi ?? "0") + ); + const branchSpentPhi = branchSpentScaled > 0n ? Number(fromScaledBig(branchSpentScaled)) : 0; + const ledgerReservedScaled = + branchSpentScaled > 0n + ? getReservedScaledForKind(h, "note") + getPendingReservedScaledForKind(h, "send") + : getReservedScaledForKind(h, "all"); + const ledgerReservedPhi = ledgerReservedScaled > 0n ? Number(fromScaledBig(ledgerReservedScaled)) : 0; + const currentAvail = Math.max( 0, - (current.originalAmount ?? 0) - sumDebits((current.debits as unknown as DebitLoose[]) || []) + (current.originalAmount ?? 0) - + sumDebits((current.debits as unknown as DebitLoose[]) || []) - + branchSpentPhi - + ledgerReservedPhi ); if (amt > currentAvail + EPS) { return setToast("Amount exceeds available"); diff --git a/src/pages/VerifyPage.css b/src/pages/VerifyPage.css index 009096980..c62f04412 100644 --- a/src/pages/VerifyPage.css +++ b/src/pages/VerifyPage.css @@ -323,9 +323,81 @@ html.verify-shell, body.verify-shell{ /* KPIs */ .vkpis{ display:grid; grid-template-columns: 1fr 1fr; gap: 8px; } -.vreceipt-row{ display:flex; align-items:center; justify-content:space-between; gap: 8px; margin-top: 6px; flex-wrap:wrap; } +/* KPI helper stack */ +.vkpi-stack{ + display: grid; + gap: 6px; + min-width: 0; +} + +/* Atlantean micro-pill (“official whisper”) under Φ-Key */ +.vkpi-whisper{ + display: inline-flex; + align-items: center; + justify-content: center; + width: fit-content; + + padding: 5px 10px; + border-radius: 999px; + + font-size: 0.60rem; + font-weight: 800; + letter-spacing: 0.22em; + text-transform: uppercase; + + color: rgba(228, 248, 255, 0.92); + border: 1px solid rgba(154, 236, 255, 0.30); + + background: + radial-gradient(circle at 20% 0%, rgba(120, 226, 255, 0.22), rgba(10, 20, 40, 0.0) 55%), + linear-gradient(120deg, rgba(20, 40, 60, 0.58), rgba(10, 14, 28, 0.78)); + + box-shadow: + 0 8px 18px rgba(10, 28, 48, 0.38), + inset 0 0 14px rgba(120, 226, 255, 0.14), + 0 0 0 1px rgba(255,255,255,0.04) inset; + + text-shadow: 0 0 10px rgba(120, 226, 255, 0.35); + + user-select: none; + pointer-events: none; /* helper only */ +} + +/* subtle living shimmer (respect reduced motion) */ +@media (prefers-reduced-motion: no-preference){ + .vkpi-whisper{ + animation: vkpiWhisperBreath 5.236s ease-in-out infinite; + } + @keyframes vkpiWhisperBreath{ + 0% { filter: brightness(0.98); transform: translateY(0px); } + 50% { filter: brightness(1.06); transform: translateY(-1px); } + 100% { filter: brightness(0.98); transform: translateY(0px); } + } +} +.vreceipt-block{ display:flex; flex-direction:column; gap: 8px; margin-top: 6px; } +.vreceipt-row{ display:flex; align-items:center; justify-content:space-between; gap: 8px; flex-wrap:wrap; } .vreceipt-label{ font-size: 0.60rem; letter-spacing: 0.28em; text-transform: uppercase; color: rgba(190,220,255,0.7); } .vreceipt-actions{ display:flex; gap: 6px; flex-wrap:wrap; } +.vreceipt-note{ display:flex; align-items:center; justify-content:space-between; gap: 8px; flex-wrap:wrap; } +.vreceipt-note-left{ display:flex; align-items:center; gap: 8px; flex-wrap:wrap; } +.vreceipt-note-actions{ display:flex; gap: 6px; flex-wrap:wrap; } +.vbtn--note{ + padding: 6px; +} +.vbtn-note-preview{ + display: block; + width: 24px; + height: 24px; + border-radius: 6px; + overflow: hidden; + border: 1px solid rgba(120,180,255,0.2); + background: rgba(6,10,18,0.8); +} +.vbtn-note-preview svg{ + display: block; + width: 100%; + height: 100%; +} .vnote-claim{ font-size: 0.65rem; letter-spacing: 0.2em; @@ -336,6 +408,20 @@ html.verify-shell, body.verify-shell{ color: rgba(230,240,255,0.9); background: rgba(18,20,30,0.35); } +.vnote-claim-wrap{ + display: flex; + flex-direction: column; + gap: 4px; +} +.vnote-claim-meta{ + display: flex; + flex-wrap: wrap; + gap: 4px 10px; + font-size: 0.58rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: rgba(200,224,255,0.8); +} .vnote-claim--unclaimed{ border-color: rgba(79,197,255,0.6); color: rgba(165,227,255,0.95); @@ -434,6 +520,11 @@ html.verify-shell, body.verify-shell{ .seal{ padding: 2px 5px; } .seal-lbl, .seal-txt{ font-size: 0.56rem; } + .vkpi-whisper{ + font-size: 0.56rem; + letter-spacing: 0.20em; + padding: 4px 9px; + } } /* Body layout */ @@ -655,6 +746,12 @@ html.verify-shell, body.verify-shell{ border-color: rgba(136,190,255,0.28); background: linear-gradient(180deg, rgba(118,170,255,0.18), rgba(118,170,255,0.08)); } +.vdrop--drag{ + border-color: rgba(140,255,241,0.65); + box-shadow: + 0 0 0 1px rgba(140,255,241,0.22) inset, + 0 12px 24px rgba(0,0,0,0.28); +} .vdrop:focus-visible{ outline: none; box-shadow: @@ -683,6 +780,34 @@ html.verify-shell, body.verify-shell{ letter-spacing: 0.14em; font-size: 0.80rem; } +.vdrop-copy{ + display:flex; + flex-direction: column; + gap: 4px; +} +.vdrop-sub{ + font-size: 0.70rem; + letter-spacing: 0.10em; + font-weight: 700; + color: var(--dim); +} +.vdrop-pills{ + display:flex; + gap: 6px; + flex-wrap: wrap; +} +.vdrop-pill{ + display:inline-flex; + align-items:center; + border-radius: 999px; + border: 1px solid rgba(255,255,255,0.2); + background: rgba(0,0,0,0.16); + padding: 2px 8px; + font-size: 0.58rem; + letter-spacing: 0.18em; + font-weight: 800; + text-transform: uppercase; +} .vdrop-mark{ margin-left: auto; @@ -696,6 +821,64 @@ html.verify-shell, body.verify-shell{ } .vphi{ width: 14px; height: 14px; display:block; opacity: 0.95; } .vdrop-mark-txt{ font-weight: 1000; letter-spacing: 0.10em; font-size: 0.74rem; opacity: 0.92; } +.vdrop-mark--phi{ + gap: 8px; + border-color: rgba(255,255,255,0.22); + background: rgba(0,0,0,0.22); + padding: 6px 10px; +} +.vdrop-keypill{ + align-items: center; + gap: 0; +} +.vdrop-keymark{ + width: 16px; + height: 16px; + display: block; + opacity: 0.95; +} +.vdrop-mark-label{ + font-size: 0.58rem; + letter-spacing: 0.24em; + font-weight: 900; + text-transform: uppercase; + color: var(--ink); + margin-left: 0; +} + +.vdrop-helper{ + margin-top: 8px; + font-size: 0.6rem; + color: rgba(220, 242, 255, 0.78); + text-transform: uppercase; + letter-spacing: 0.18em; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid rgba(140, 220, 255, 0.2); + background: linear-gradient(120deg, rgba(20, 40, 60, 0.6), rgba(16, 20, 36, 0.78)); + box-shadow: 0 4px 12px rgba(12, 36, 64, 0.28), inset 0 0 10px rgba(140, 220, 255, 0.1); +} + +.vdrop-detect{ + margin-top: 6px; + font-size: 0.62rem; + color: rgba(228, 248, 255, 0.94); + text-transform: uppercase; + letter-spacing: 0.16em; + font-weight: 700; + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid rgba(154, 236, 255, 0.32); + background: radial-gradient(circle at top, rgba(120, 226, 255, 0.16), rgba(10, 20, 40, 0.82)); + box-shadow: 0 6px 16px rgba(10, 28, 48, 0.4), inset 0 0 12px rgba(120, 226, 255, 0.18); + text-shadow: 0 0 6px rgba(120, 226, 255, 0.45); +} .vcontrol-row{ display:flex; @@ -1013,6 +1196,81 @@ html.verify-shell, body.verify-shell{ color: rgba(185,220,255,0.7); } +/* Glass dialog (note preview) */ +dialog.glass-modal{ + box-sizing: border-box; + inline-size: min(1000px, calc(100vw - (var(--pad) * 2))); + max-inline-size: calc(100vw - (var(--pad) * 2)); + max-height: calc(100vh - 2 * clamp(8px, 6vh, 24px)); + margin-block: clamp(8px, 6vh, 24px); + margin-inline: auto; + border: 0; + padding: 0; + border-radius: var(--br2); + background: linear-gradient(180deg, rgba(10,12,20,0.92), rgba(8,12,18,0.88)); + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.55), inset 0 0 0 1px rgba(255,255,255,0.08); + color: var(--ink); + overflow: hidden; +} + +dialog.glass-modal::backdrop{ + background: radial-gradient(900px 600px at 80% -10%, rgba(120,200,255,0.20), transparent 40%), + radial-gradient(900px 600px at 10% 110%, rgba(90,255,220,0.16), transparent 40%), + rgba(0, 0, 10, 0.55); + -webkit-backdrop-filter: blur(4px); + backdrop-filter: blur(4px); +} + +dialog.glass-modal .modal-topbar{ + position: sticky; + top: 0; + z-index: 2; + min-height: 48px; + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 8px; + background: linear-gradient(180deg, rgba(10, 12, 20, 0.9), rgba(10, 12, 20, 0.7)); + border-bottom: 1px solid rgba(255,255,255,0.08); +} + +dialog.glass-modal .modal-topbar .close-btn{ + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 999px; + border: 1px solid rgba(255,255,255,0.18); + font-size: 18px; + font-weight: 700; + line-height: 1; + color: rgba(240,245,255,0.98); + background: radial-gradient(circle at 30% 0%, rgba(255, 255, 255, 0.22), transparent 55%), rgba(15, 23, 42, 0.98); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.65); + cursor: pointer; + -webkit-backdrop-filter: blur(10px); + backdrop-filter: blur(10px); + transition: transform 0.12s ease, box-shadow 0.18s ease, background 0.18s ease, color 0.18s ease; +} + +dialog.glass-modal .modal-topbar .close-btn:hover{ + background: radial-gradient(circle at 30% 0%, rgba(255, 255, 255, 0.28), transparent 55%), rgba(15, 23, 42, 1); + box-shadow: 0 7px 20px rgba(0, 0, 0, 0.72); + transform: translateY(-1px); +} + +dialog.glass-modal .modal-topbar .close-btn:active{ + transform: translateY(1px); + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.65); +} + +dialog.glass-modal .modal-topbar .close-btn:focus-visible{ + outline: 2px solid rgba(120,200,255,0.65); + outline-offset: 2px; +} + /* Modal (unchanged) */ .vmodal-backdrop{ position: fixed; @@ -1635,3 +1893,90 @@ html.verify-shell, body.verify-shell{ margin: 0 !important; transform: translateY(0) !important; } + +.vreceipt-actions .vbtn .vicon-word{ + width: 22px; + height: 16px; +} +/* ──────────────────────────────────────────────────────────────── + FIX: Note modal preview button — perfect centering + (prevents .vbtn-ic 16x16 overrides from squashing the thumbnail) +──────────────────────────────────────────────────────────────── */ + +.vbtn--note{ + /* make centering deterministic */ + display: grid !important; + place-items: center !important; + padding: 0 !important; /* kill any asymmetric padding math */ +} + +/* If your note button still uses the standard icon wrapper, + override it to match the thumbnail size */ +.vbtn--note .vbtn-ic{ + width: 24px !important; + height: 24px !important; + display: grid !important; + place-items: center !important; + margin: 0 !important; + line-height: 1 !important; +} + +/* The thumbnail itself must be a centered 1:1 viewport */ +.vbtn--note .vbtn-note-preview{ + width: 24px !important; + height: 24px !important; + display: grid !important; + place-items: center !important; + margin: 0 !important; + border-radius: 6px; + overflow: hidden; +} + +/* Center any kind of payload (inline svg, img, nested wrappers) */ +.vbtn--note .vbtn-note-preview > svg, +.vbtn--note .vbtn-note-preview > img, +.vbtn--note .vbtn-note-preview > *{ + display: block !important; + width: 100% !important; + height: 100% !important; + margin: 0 auto !important; +} + +/* If the preview is an , enforce true center-crop/center-fit */ +.vbtn--note .vbtn-note-preview > img{ + object-fit: cover; + object-position: center; +} +/* Center the ⬇︎Φ download button (note row) */ +.vreceipt-note-actions button.vbtn[aria-label="Download fresh note PNG"]{ + display: grid !important; + place-items: center !important; + + /* make it a true square like your other micro-buttons */ + width: 28px; + height: 28px; + padding: 0 !important; + border-radius: 10px; +} + +.vbtn-ic--note-download{ + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + width: 20px; + height: 20px; +} + +.vbtn-ic--note-download .vnote-download-mark{ + width: 15px; + height: 15px; + display: block; +} + +.vbtn-ic--note-download .vnote-phi-mark{ + width: 12px; + height: 12px; + display: block; + object-fit: contain; +} diff --git a/src/pages/VerifyPage.tsx b/src/pages/VerifyPage.tsx index fb5a3d278..66112842e 100644 --- a/src/pages/VerifyPage.tsx +++ b/src/pages/VerifyPage.tsx @@ -1,7 +1,7 @@ // src/pages/VerifyPage.tsx "use client"; -import React, { useCallback, useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from "react"; +import React, { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactElement, type ReactNode } from "react"; import "./VerifyPage.css"; import VerifierFrame from "../components/KaiVoh/VerifierFrame"; @@ -36,7 +36,7 @@ import { type ProofBundleLike, } from "../components/KaiVoh/verifierProof"; import { extractProofBundleMetaFromSvg, type ProofBundleMeta } from "../utils/sigilMetadata"; -import { derivePhiKeyFromSig, genNonce } from "../components/VerifierStamper/sigilUtils"; +import { derivePhiKeyFromSig, genNonce, stableStringify } from "../components/VerifierStamper/sigilUtils"; import { tryVerifyGroth16 } from "../components/VerifierStamper/zk"; import { isKASAuthorSig, type KASAuthorSig } from "../utils/authorSig"; import { @@ -57,9 +57,9 @@ import { deriveOwnerPhiKeyFromReceive, type OwnerKeyDerivation } from "../utils/ import { base64UrlDecode, sha256Hex } from "../utils/sha256"; import { insertPngTextChunks, readPngTextChunk } from "../utils/pngChunks"; import { getKaiPulseEternalInt } from "../SovereignSolar"; -import { getSendRecordByNonce, listen, markConfirmedByNonce } from "../utils/sendLedger"; +import { getSendRecordByNonce, listen, markConfirmedByNonce, recordSend } from "../utils/sendLedger"; import { recordSigilTransferMovement } from "../utils/sigilTransferRegistry"; -import { isNoteClaimed, markNoteClaimed } from "../components/SigilExplorer/registryStore"; +import { getNoteClaimInfo, getNoteClaimLeader, isNoteClaimed, markNoteClaimed, listenRegistry } from "../components/SigilExplorer/registryStore"; import { pullAndImportRemoteUrls } from "../components/SigilExplorer/remotePull"; import { useKaiTicker } from "../hooks/useKaiTicker"; import { useValuation } from "./SigilPage/useValuation"; @@ -69,6 +69,13 @@ import type { VerifiedCardData } from "../og/types"; import { jcsCanonicalize } from "../utils/jcs"; import { svgCanonicalForHash } from "../utils/svgProof"; import { svgStringToPngBlob, triggerDownload } from "../components/exhale-note/svgToPng"; +import NotePrinter from "../components/ExhaleNote"; +import { buildNotePayload } from "../components/verifier/utils/notePayload"; +import { buildBanknoteSVG } from "../components/exhale-note/banknoteSvg"; +import type { BanknoteInputs as NoteBanknoteInputs, NoteSendPayload, NoteSendResult } from "../components/exhale-note/types"; +import { safeShowDialog } from "../components/verifier/utils/modal"; +import type { SigilMetadata } from "../components/verifier/types/local"; +import { toScaledBig } from "../components/verifier/utils/decimal"; import useRollingChartSeries from "../components/VerifierStamper/hooks/useRollingChartSeries"; import { BREATH_MS } from "../components/valuation/constants"; import { @@ -111,6 +118,18 @@ function formatProofValue(value: unknown): string { } } +function normalizeClaimPulse(value: number | null): number | null { + if (value == null || !Number.isFinite(value) || value <= 0) return null; + if (value > 100_000_000_000) return getKaiPulseEternalInt(new Date(value)); + return Math.trunc(value); +} + +function formatClaimPulse(value: number | null): string { + const normalized = normalizeClaimPulse(value); + if (!normalized || !Number.isFinite(normalized)) return "—"; + return String(normalized); +} + async function sha256Bytes(data: Uint8Array): Promise { return (await sha256Hex(data)).toLowerCase(); } @@ -127,12 +146,34 @@ function parseJsonString(value: unknown): unknown { } } +function normalizeCanonicalHash(value: string | null | undefined): string { + if (typeof value !== "string") return ""; + const trimmed = value.trim(); + return trimmed ? trimmed.toLowerCase() : ""; +} + +async function resolveNoteParentCanonical(meta: SigilMetadata | null, payload?: NoteSendPayload): Promise { + const fromPayload = normalizeCanonicalHash(payload?.parentCanonical); + if (fromPayload) return fromPayload; + const fromMeta = normalizeCanonicalHash(meta?.canonicalHash); + if (fromMeta) return fromMeta; + if (!meta) return ""; + const pulse = Number.isFinite(meta.pulse ?? NaN) ? meta.pulse : meta.kaiPulse; + const beat = Number.isFinite(meta.beat ?? NaN) ? meta.beat : undefined; + const stepIndex = Number.isFinite(meta.stepIndex ?? NaN) ? meta.stepIndex : undefined; + const chakraDay = typeof meta.chakraDay === "string" ? meta.chakraDay : ""; + const seed = `${pulse ?? ""}|${beat ?? ""}|${stepIndex ?? ""}|${chakraDay ?? ""}`; + if (seed === "|||") return ""; + return (await sha256Hex(seed)).toLowerCase(); +} + type NoteSendMeta = { parentCanonical: string; transferNonce: string; amountPhi?: number; amountUsd?: number; childCanonical?: string; + transferLeafHashSend?: string; }; function buildNoteSendMetaFromObject(value: unknown): NoteSendMeta | null { @@ -142,19 +183,62 @@ function buildNoteSendMetaFromObject(value: unknown): NoteSendMeta | null { const amountPhi = typeof value.amountPhi === "number" && Number.isFinite(value.amountPhi) ? value.amountPhi : undefined; const amountUsd = typeof value.amountUsd === "number" && Number.isFinite(value.amountUsd) ? value.amountUsd : undefined; const childCanonical = typeof value.childCanonical === "string" ? value.childCanonical.trim() : undefined; + const transferLeafHashSend = typeof value.transferLeafHashSend === "string" ? value.transferLeafHashSend.trim() : undefined; if (!parentCanonical || !transferNonce) return null; - return { parentCanonical, transferNonce, amountPhi, amountUsd, childCanonical }; + return { parentCanonical, transferNonce, amountPhi, amountUsd, childCanonical, transferLeafHashSend }; +} +function readLooseString(obj: Record, ...keys: string[]): string { + for (const k of keys) { + const v = obj[k]; + if (typeof v === "string") { + const t = v.trim(); + if (t) return t; + } + } + return ""; +} + +function readLooseNumber(obj: Record, ...keys: string[]): number | undefined { + for (const k of keys) { + const v = obj[k]; + if (typeof v === "number" && Number.isFinite(v)) return v; + if (typeof v === "string") { + const n = Number(v); + if (Number.isFinite(n)) return n; + } + } + return undefined; +} + +function buildNoteSendMetaFromObjectLoose(value: unknown): NoteSendMeta | null { + if (!isRecord(value)) return null; + + const parentCanonical = readLooseString(value, "parentCanonical", "parentHash", "parent"); + const transferNonce = readLooseString(value, "transferNonce", "nonce", "n"); + + if (!parentCanonical || !transferNonce) return null; + + const amountPhi = readLooseNumber(value, "amountPhi", "phi", "valuePhi"); + const amountUsd = readLooseNumber(value, "amountUsd", "usd", "valueUsd"); + + const childCanonical = readLooseString(value, "childCanonical", "childHash", "canonicalHash", "hash") || undefined; + + const transferLeafHashSend = + readLooseString(value, "transferLeafHashSend", "transferLeafHash", "leafHash", "l") || undefined; + + return { parentCanonical, transferNonce, amountPhi, amountUsd, childCanonical, transferLeafHashSend }; } function parseNoteSendMeta(raw: string | null): NoteSendMeta | null { if (!raw) return null; try { - return buildNoteSendMetaFromObject(JSON.parse(raw)); + return buildNoteSendMetaFromObjectLoose(JSON.parse(raw)); } catch { return null; } } + function parseNoteSendPayload(raw: string | null): Record | null { if (!raw) return null; try { @@ -169,6 +253,14 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } +function readRecordString(value: Record | null | undefined, key: string): string | null { + if (!value) return null; + const raw = value[key]; + if (typeof raw !== "string") return null; + const trimmed = raw.trim(); + return trimmed ? trimmed : null; +} + function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { const buf = new ArrayBuffer(bytes.byteLength); new Uint8Array(buf).set(bytes); @@ -654,6 +746,63 @@ function ProofMark(): ReactElement { ); } +function SignProofIcon(): ReactElement { + const gradientId = useId(); + const fillId = `${gradientId}-fill`; + return ( + + ); +} + +function DownloadPngIcon(): ReactElement { + const gradientId = useId(); + const fillId = `${gradientId}-fill`; + return ( + + ); +} + +function NoteDownloadIcon(): ReactElement { + return ( + + ); +} + /* ──────────────────────────────────────────────────────────────── UI atoms ─────────────────────────────────────────────────────────────── */ @@ -841,6 +990,8 @@ export default function VerifyPage(): ReactElement { const lastAutoScanKeyRef = useRef(null); const noteSendConfirmedRef = useRef(null); const noteClaimRemoteCheckedRef = useRef(null); + const noteDownloadBypassRef = useRef(false); + const noteDownloadInFlightRef = useRef(false); const slugRaw = useMemo(() => readSlugFromLocation(), []); const slug = useMemo(() => parseSlug(slugRaw), [slugRaw]); @@ -854,6 +1005,7 @@ export default function VerifyPage(): ReactElement { const [sharedReceipt, setSharedReceipt] = useState(initialReceiptResult.receipt); const [noteSendMeta, setNoteSendMeta] = useState(null); const [noteSendPayloadRaw, setNoteSendPayloadRaw] = useState | null>(null); + const [noteClaimedImmediate, setNoteClaimedImmediate] = useState(false); const [noteSvgFromPng, setNoteSvgFromPng] = useState(""); const [noteProofBundleJson, setNoteProofBundleJson] = useState(""); @@ -900,6 +1052,11 @@ export default function VerifyPage(): ReactElement { setLedgerTick((prev) => prev + 1); }); }, []); +useEffect(() => { + return listenRegistry(() => { + setRegistryTick((prev) => prev + 1); + }); +}, []); const { pulse: currentPulse } = useKaiTicker(); const searchParams = useMemo(() => new URLSearchParams(typeof window !== "undefined" ? window.location.search : ""), []); @@ -963,6 +1120,11 @@ export default function VerifyPage(): ReactElement { return readEmbeddedPhiAmount(result.embedded.raw) ?? readEmbeddedPhiAmount(embeddedProof?.raw); }, [embeddedProof?.raw, result]); + const effectiveNoteMeta = useMemo( + () => noteSendMeta ?? (noteSendPayloadRaw ? buildNoteSendMetaFromObjectLoose(noteSendPayloadRaw) : null), + [noteSendMeta, noteSendPayloadRaw], + ); + const noteValuePhi = noteSendMeta?.amountPhi ?? null; const noteValueUsd = noteSendMeta?.amountUsd ?? null; @@ -988,18 +1150,48 @@ export default function VerifyPage(): ReactElement { : "Live glyph valuation"; const noteSendRecord = useMemo( - () => (noteSendMeta ? getSendRecordByNonce(noteSendMeta.parentCanonical, noteSendMeta.transferNonce) : null), - [noteSendMeta, ledgerTick, registryTick], + () => (effectiveNoteMeta ? getSendRecordByNonce(effectiveNoteMeta.parentCanonical, effectiveNoteMeta.transferNonce) : null), + [effectiveNoteMeta, ledgerTick, registryTick], ); - const noteClaimed = - Boolean(noteSendRecord?.confirmed) || - (noteSendMeta ? isNoteClaimed(noteSendMeta.parentCanonical, noteSendMeta.transferNonce) : false); - const noteClaimStatus = noteSendMeta ? (noteClaimed ? "CLAIMED" : "UNCLAIMED") : null; - const isExhaleNoteUpload = Boolean(noteSendMeta || noteSvgFromPng || noteProofBundleJson); + const noteClaimInfo = useMemo( + () => (effectiveNoteMeta ? getNoteClaimInfo(effectiveNoteMeta.parentCanonical, effectiveNoteMeta.transferNonce) : null), + [effectiveNoteMeta, registryTick], + ); + const noteClaimLeader = useMemo( + () => (effectiveNoteMeta ? getNoteClaimLeader(effectiveNoteMeta.parentCanonical) : null), + [effectiveNoteMeta, registryTick], + ); + const noteClaimedPulse = useMemo(() => { + const registryPulse = normalizeClaimPulse(noteClaimInfo?.claimedPulse ?? null); + if (registryPulse != null) return registryPulse; + return normalizeClaimPulse( + embeddedProof?.receivePulse ?? sharedReceipt?.receivePulse ?? receiveSig?.createdAtPulse ?? null, + ); + }, [embeddedProof?.receivePulse, noteClaimInfo?.claimedPulse, receiveSig?.createdAtPulse, sharedReceipt?.receivePulse]); + const noteClaimNonce = noteClaimInfo?.nonce ?? effectiveNoteMeta?.transferNonce ?? ""; + const noteClaimLeaderNonce = noteClaimLeader?.nonce ?? ""; + const noteClaimTransferHash = + noteClaimInfo?.transferLeafHash ?? + effectiveNoteMeta?.transferLeafHashSend ?? + readRecordString(noteSendPayloadRaw, "transferLeafHashSend") ?? + noteSendRecord?.transferLeafHashSend ?? + ""; +const noteClaimedFinal = + Boolean(noteSendRecord?.confirmed) || + (effectiveNoteMeta ? isNoteClaimed(effectiveNoteMeta.parentCanonical, effectiveNoteMeta.transferNonce) : false); + + const noteClaimed = noteClaimedImmediate || noteClaimedFinal; + const noteClaimStatus = effectiveNoteMeta ? (noteClaimed ? "CLAIMED — SEAL Owned" : "UNCLAIMED — SEAL Available") : null; + const noteClaimPulseLabel = useMemo(() => formatClaimPulse(noteClaimedPulse), [noteClaimedPulse]); + const noteClaimNonceShort = noteClaimNonce ? ellipsizeMiddle(noteClaimNonce, 8, 6) : "—"; + const noteClaimLeaderShort = noteClaimLeaderNonce ? ellipsizeMiddle(noteClaimLeaderNonce, 8, 6) : "—"; + const noteClaimHashShort = noteClaimTransferHash ? ellipsizeMiddle(noteClaimTransferHash, 10, 8) : "—"; + const isNoteUpload = Boolean(noteSendMeta || noteSendPayloadRaw || noteSvgFromPng); + const isExhaleNoteUpload = isNoteUpload; useEffect(() => { - if (!noteSendMeta || noteClaimed) return; - const key = `${noteSendMeta.parentCanonical}|${noteSendMeta.transferNonce}`; + if (!effectiveNoteMeta || noteClaimedFinal) return; + const key = `${effectiveNoteMeta.parentCanonical}|${effectiveNoteMeta.transferNonce}`; if (noteClaimRemoteCheckedRef.current === key) return; noteClaimRemoteCheckedRef.current = key; const ac = new AbortController(); @@ -1017,7 +1209,7 @@ export default function VerifyPage(): ReactElement { return () => { ac.abort(); }; - }, [noteClaimed, noteSendMeta]); + }, [effectiveNoteMeta, noteClaimedFinal]); const isReceiveGlyph = useMemo(() => { const mode = embeddedProof?.mode ?? sharedReceipt?.mode; @@ -1078,6 +1270,8 @@ export default function VerifyPage(): ReactElement { const [openZkProof, setOpenZkProof] = useState(false); const [openZkInputs, setOpenZkInputs] = useState(false); const [openZkHints, setOpenZkHints] = useState(false); + const noteDlgRef = useRef(null); + const [noteOpen, setNoteOpen] = useState(false); // Live chart popover const [chartOpen, setChartOpen] = useState(false); @@ -1299,6 +1493,8 @@ export default function VerifyPage(): ReactElement { setNotice(""); setNoteSendMeta(null); setNoteSendPayloadRaw(null); + setNoteClaimedImmediate(false); + noteDownloadBypassRef.current = false; setNoteSvgFromPng(""); setNoteProofBundleJson(""); }, @@ -1313,6 +1509,8 @@ export default function VerifyPage(): ReactElement { } setNoteSendMeta(null); setNoteSendPayloadRaw(null); + setNoteClaimedImmediate(false); + noteDownloadBypassRef.current = false; setNoteSvgFromPng(""); setNoteProofBundleJson(""); try { @@ -1335,8 +1533,12 @@ export default function VerifyPage(): ReactElement { setSvgText(""); setResult({ status: "idle" }); setNotice("Receipt PNG loaded."); - setNoteSendMeta(parseNoteSendMeta(noteSendJson)); - setNoteSendPayloadRaw(parseNoteSendPayload(noteSendJson)); +const payloadRaw = parseNoteSendPayload(noteSendJson); +const meta = parseNoteSendMeta(noteSendJson) ?? (payloadRaw ? buildNoteSendMetaFromObjectLoose(payloadRaw) : null); + +setNoteSendMeta(meta); +setNoteSendPayloadRaw(payloadRaw); + setNoteSvgFromPng(noteSvg ?? ""); setNoteProofBundleJson(text); } catch (err) { @@ -1355,6 +1557,8 @@ export default function VerifyPage(): ReactElement { } setNoteSendMeta(null); setNoteSendPayloadRaw(null); + setNoteClaimedImmediate(false); + noteDownloadBypassRef.current = false; setNoteSvgFromPng(""); setNoteProofBundleJson(""); try { @@ -1403,29 +1607,78 @@ export default function VerifyPage(): ReactElement { [onPickFile, onPickReceiptPng, onPickReceiptPdf, slug], ); - const confirmNoteSend = useCallback(() => { - if (!noteSendMeta) return; - const key = `${noteSendMeta.parentCanonical}|${noteSendMeta.transferNonce}`; +const confirmNoteSend = useCallback( + (override?: { + meta?: NoteSendMeta; + payloadRaw?: Record | null; + claimedPulse?: number; + }) => { + const overridePayload = override?.payloadRaw ?? null; + // ✅ tolerate missing noteSendMeta (common on receipt PNGs) + const effectiveMeta = + override?.meta ?? + noteSendMeta ?? + (overridePayload ? buildNoteSendMetaFromObjectLoose(overridePayload) : null) ?? + (noteSendPayloadRaw ? buildNoteSendMetaFromObjectLoose(noteSendPayloadRaw) : null); + + if (!effectiveMeta) return; + + if (!noteSendMeta) { + setNoteSendMeta(effectiveMeta); + } + setNoteClaimedImmediate(true); + + const key = `${effectiveMeta.parentCanonical}|${effectiveMeta.transferNonce}`; if (noteSendConfirmedRef.current === key) return; noteSendConfirmedRef.current = key; + + const claimedPulse = override?.claimedPulse ?? currentPulse ?? getKaiPulseEternalInt(new Date()); + + // ✅ don’t rely on noteSendRecord memo (it’s tied to noteSendMeta); fetch directly + const rec = getSendRecordByNonce(effectiveMeta.parentCanonical, effectiveMeta.transferNonce); + + const transferLeafHash = + effectiveMeta.transferLeafHashSend ?? + readRecordString(overridePayload, "transferLeafHashSend") ?? + readRecordString(overridePayload, "transferLeafHash") ?? + readRecordString(overridePayload, "leafHash") ?? + readRecordString(noteSendPayloadRaw, "transferLeafHashSend") ?? + readRecordString(noteSendPayloadRaw, "transferLeafHash") ?? + readRecordString(noteSendPayloadRaw, "leafHash") ?? + rec?.transferLeafHashSend ?? + undefined; + try { - markConfirmedByNonce(noteSendMeta.parentCanonical, noteSendMeta.transferNonce); - markNoteClaimed(noteSendMeta.parentCanonical, noteSendMeta.transferNonce, { - childCanonical: noteSendMeta.childCanonical, + // Send ledger confirm (best-effort) + markConfirmedByNonce(effectiveMeta.parentCanonical, effectiveMeta.transferNonce); + + // Note claim registry (this is what your UI uses to show CLAIMED) + markNoteClaimed(effectiveMeta.parentCanonical, effectiveMeta.transferNonce, { + childCanonical: effectiveMeta.childCanonical, + transferLeafHash, + claimedPulse, }); - if (noteSendMeta.childCanonical && noteSendMeta.amountPhi) { + + // Optional movement trace + if (effectiveMeta.childCanonical && effectiveMeta.amountPhi) { recordSigilTransferMovement({ - hash: noteSendMeta.childCanonical, + hash: effectiveMeta.childCanonical, direction: "receive", - amountPhi: noteSendMeta.amountPhi, - amountUsd: noteSendMeta.amountUsd != null ? noteSendMeta.amountUsd.toFixed(2) : undefined, + amountPhi: effectiveMeta.amountPhi, + amountUsd: effectiveMeta.amountUsd != null ? effectiveMeta.amountUsd.toFixed(2) : undefined, }); } } catch (err) { // eslint-disable-next-line no-console console.error("note send confirm failed", err); + } finally { + // ✅ force re-render even if iOS storage/broadcast events don’t fire + setLedgerTick((prev) => prev + 1); + setRegistryTick((prev) => prev + 1); } - }, [noteSendMeta]); + }, + [currentPulse, noteSendMeta, noteSendPayloadRaw], +); const runOwnerAuthFlow = useCallback( async (args: { @@ -1571,7 +1824,7 @@ export default function VerifyPage(): ReactElement { const runVerify = useCallback(async (): Promise => { const raw = svgText.trim(); if (!raw) { - setResult({ status: "error", message: "Inhale or remember the sealed SVG (ΦKey).", slug }); + setResult({ status: "error", message: "Inhale or remember the sealed SVG (Sigil-Glyph).", slug }); return; } const receipt = parseSharedReceiptFromText(raw); @@ -2223,6 +2476,35 @@ if (verified && typeof cacheBundleHash === "string" && cacheBundleHash.trim().le sharedReceipt?.receivePulse, effectiveReceiveSig?.createdAtPulse, ]); + +useEffect(() => { + if (!effectiveNoteMeta) return; + if (effectiveReceivePulse == null) return; + + const normalizedPulse = normalizeClaimPulse(effectiveReceivePulse); + if (normalizedPulse == null) return; + + const transferLeafHash = + effectiveNoteMeta.transferLeafHashSend ?? + readRecordString(noteSendPayloadRaw, "transferLeafHashSend") ?? + noteSendRecord?.transferLeafHashSend ?? + undefined; + + try { + markNoteClaimed(effectiveNoteMeta.parentCanonical, effectiveNoteMeta.transferNonce, { + childCanonical: effectiveNoteMeta.childCanonical, + transferLeafHash, + claimedPulse: normalizedPulse, + }); + + // ✅ force UI refresh on mobile + setRegistryTick((prev) => prev + 1); + } catch (err) { + // eslint-disable-next-line no-console + console.error("note claim pulse hydrate failed", err); + } +}, [effectiveNoteMeta, effectiveReceivePulse, noteSendPayloadRaw, noteSendRecord]); + const effectiveReceiveBundleHash = useMemo(() => { if (embeddedProof?.receiveBundleHash) return embeddedProof.receiveBundleHash; if (sharedReceipt?.receiveBundleHash) return sharedReceipt.receiveBundleHash; @@ -2529,8 +2811,8 @@ if (verified && typeof cacheBundleHash === "string" && cacheBundleHash.trim().le const badge: { kind: BadgeKind; title: string; subtitle?: string } = useMemo(() => { if (busy) return { kind: "busy", title: "SEALING", subtitle: "Deterministic proof rails executing." }; if (result.status === "ok") return { kind: "ok", title: "PROOF OF BREATH™", subtitle: "Human-origin seal affirmed." }; - if (result.status === "error") return { kind: "fail", title: "REJECTED", subtitle: "Inhale a sealed ΦKey, then verify." }; - return { kind: "idle", title: "STANDBY", subtitle: "Inhale a ΦKey to begin." }; + if (result.status === "error") return { kind: "fail", title: "REJECTED", subtitle: "Inhale a sealed file, then verify." }; + return { kind: "idle", title: "STANDBY", subtitle: "Inhale a Sigil / Seal / Note to begin." }; }, [busy, result.status]); const kpiPulse = useMemo( @@ -2678,6 +2960,124 @@ body: [ }; }, [hasKASAuthSig, result.status, sealKAS, sealPopover, sealStateLabel, sealZK]); + const noteMeta = useMemo(() => { + if (result.status !== "ok") return null; + const raw = result.embedded.raw; + return isRecord(raw) ? (raw as SigilMetadata) : null; + }, [result]); + const noteOriginCanonical = useMemo(() => normalizeCanonicalHash(noteMeta?.canonicalHash), [noteMeta?.canonicalHash]); + + const notePulseNow = useMemo(() => currentPulse ?? getKaiPulseEternalInt(new Date()), [currentPulse]); +const noteInitial = useMemo(() => { + const rawSvg = svgText.trim() ? svgText.trim() : null; + + const base = buildNotePayload({ + meta: noteMeta, + sigilSvgRaw: rawSvg, + verifyUrl: currentVerifyUrl, + pulseNow: notePulseNow, + }); + + // ✅ Extract proof bundle straight from the SVG text (first-render safe) + const extracted = rawSvg ? extractProofBundleMetaFromSvg(rawSvg) : null; + + const rawBundle = extracted?.raw ?? embeddedProof?.raw; + const rawRecord = isRecord(rawBundle) ? rawBundle : null; + + const proofBundleJson = rawRecord ? JSON.stringify(rawRecord) : ""; + + const bundleHashValue = + extracted?.bundleHash ?? + embeddedProof?.bundleHash ?? + sharedReceipt?.bundleHash ?? + (rawRecord && typeof rawRecord.bundleHash === "string" ? (rawRecord.bundleHash as string) : ""); + + const receiptHashValue = + (extracted as { receiptHash?: string } | null)?.receiptHash ?? + embeddedProof?.receiptHash ?? + sharedReceipt?.receiptHash ?? + (rawRecord && typeof rawRecord.receiptHash === "string" ? (rawRecord.receiptHash as string) : ""); + + const verifiedAtPulseValue = + typeof extracted?.verifiedAtPulse === "number" + ? extracted.verifiedAtPulse + : typeof embeddedProof?.verifiedAtPulse === "number" + ? embeddedProof.verifiedAtPulse + : typeof sharedReceipt?.verifiedAtPulse === "number" + ? sharedReceipt.verifiedAtPulse + : rawRecord && typeof rawRecord.verifiedAtPulse === "number" + ? (rawRecord.verifiedAtPulse as number) + : undefined; + + const capsuleHashValue = + extracted?.capsuleHash ?? + embeddedProof?.capsuleHash ?? + sharedReceipt?.capsuleHash ?? + (rawRecord && typeof rawRecord.capsuleHash === "string" ? (rawRecord.capsuleHash as string) : ""); + + const svgHashValue = + extracted?.svgHash ?? + embeddedProof?.svgHash ?? + sharedReceipt?.svgHash ?? + (rawRecord && typeof rawRecord.svgHash === "string" ? (rawRecord.svgHash as string) : ""); + + return { + ...base, + proofBundleJson, + bundleHash: bundleHashValue, + receiptHash: receiptHashValue, + verifiedAtPulse: verifiedAtPulseValue, + capsuleHash: capsuleHashValue, + svgHash: svgHashValue, + }; +}, [currentVerifyUrl, embeddedProof, noteMeta, notePulseNow, sharedReceipt, svgText]); + + const canShowNotePreview = result.status === "ok" && Boolean(svgText.trim()); + const notePreviewSvg = useMemo(() => { + if (!canShowNotePreview) return ""; + const valuePhi = displayPhi != null ? displayPhi.toFixed(4) : noteInitial.valuePhi ?? ""; + const valueUsd = displayUsd != null ? fmtUsd(displayUsd) : ""; + return buildBanknoteSVG({ + ...noteInitial, + valuePhi, + valueUsd, + sigilSvg: svgText.trim(), + verifyUrl: currentVerifyUrl, + }); + }, [canShowNotePreview, currentVerifyUrl, displayPhi, displayUsd, noteInitial, svgText]); + + const handleNoteSend = useCallback( + async (payload: NoteSendPayload): Promise => { + const parentCanonical = await resolveNoteParentCanonical(noteMeta, payload); + const transferNonce = payload.transferNonce?.trim() ?? ""; + if (!parentCanonical || !transferNonce) return payload; + setNoteSendMeta({ + parentCanonical, + transferNonce, + amountPhi: payload.amountPhi, + amountUsd: payload.amountUsd, + childCanonical: payload.childCanonical, + transferLeafHashSend: payload.transferLeafHashSend, + }); + setNoteSendPayloadRaw(payload as Record); + return parentCanonical === payload.parentCanonical ? payload : { ...payload, parentCanonical }; + }, + [noteMeta], + ); + + const openNote = useCallback(() => { + if (!noteDlgRef.current) return; + safeShowDialog(noteDlgRef.current); + setNoteOpen(true); + }, []); + + const closeNote = useCallback(() => { + if (!noteDlgRef.current) return; + noteDlgRef.current.close(); + noteDlgRef.current.setAttribute("data-open", "false"); + setNoteOpen(false); + }, []); + const hasSvgBytes = Boolean(svgText.trim()); const expectedSvgHash = sharedReceipt?.svgHash ?? embeddedProof?.svgHash ?? ""; const identityStatusLabel = hasKASOwnerSig ? ownerAuthStatus || "Not present" : ""; @@ -2962,58 +3362,192 @@ React.useEffect(() => { verificationVersion, zkMeta?.zkPoseidonHash, ]); +const onDownloadNotePng = useCallback(async () => { + if (noteDownloadInFlightRef.current) return; + noteDownloadInFlightRef.current = true; + noteDownloadBypassRef.current = true; + + // Must have a note SVG loaded (we are “re-minting” from an existing note) + if (!noteSvgFromPng) { + noteDownloadBypassRef.current = false; + noteDownloadInFlightRef.current = false; + return; + } + + // ──────────────────────────────────────────────────────────────── + // 1) Identify the CURRENT note leaf (this is the parent that must be claimed) + // ──────────────────────────────────────────────────────────────── + const parentMeta: NoteSendMeta | null = + effectiveNoteMeta ?? + (noteSendMeta ?? (noteSendPayloadRaw ? buildNoteSendMetaFromObjectLoose(noteSendPayloadRaw) : null)); + + const parentPayloadRaw: Record | null = noteSendPayloadRaw ?? null; + + if (!parentMeta?.parentCanonical || !parentMeta.transferNonce) { + setNotice("Missing parent rotation-seal (transferNonce). Cannot mint a child note from this file."); + noteDownloadBypassRef.current = false; + noteDownloadInFlightRef.current = false; + return; + } + + const spentCanonical = + normalizeCanonicalHash(parentMeta.childCanonical) || normalizeCanonicalHash(parentMeta.parentCanonical); + if (!spentCanonical) { + setNotice("Missing parent canonical. Cannot mint a child note from this file."); + noteDownloadBypassRef.current = false; + noteDownloadInFlightRef.current = false; + return; + } + +const alreadySpent = + isNoteClaimed(parentMeta.parentCanonical, parentMeta.transferNonce) || + Boolean(getSendRecordByNonce(parentMeta.parentCanonical, parentMeta.transferNonce)?.confirmed); + +if (!noteDownloadBypassRef.current && alreadySpent) { + setNotice("Already claimed/spent."); + noteDownloadBypassRef.current = false; + noteDownloadInFlightRef.current = false; + return; +} + + + // If parent is already claimed, do not allow minting a child + if (!noteDownloadBypassRef.current && noteClaimedFinal) { + noteDownloadBypassRef.current = false; + noteDownloadInFlightRef.current = false; + return; + } + + const claimedPulse = currentPulse ?? getKaiPulseEternalInt(new Date()); + + try { + // ──────────────────────────────────────────────────────────────── + // 2) Build NEW child note payload (new nonce) to embed into the export + // IMPORTANT: this NEW nonce must NOT be marked claimed here. + // ──────────────────────────────────────────────────────────────── + const payloadBase: Record = parentPayloadRaw ? { ...parentPayloadRaw } : {}; + + // Strip any parent-leaf fields we must not carry forward + delete (payloadBase as { transferLeafHashSend?: unknown }).transferLeafHashSend; + delete (payloadBase as { transferLeafHash?: unknown }).transferLeafHash; + delete (payloadBase as { leafHash?: unknown }).leafHash; + delete (payloadBase as { childCanonical?: unknown }).childCanonical; - const onDownloadNotePng = useCallback(async () => { - if (!noteSvgFromPng || noteClaimed) return; - const payloadBase = noteSendPayloadRaw - ? { ...noteSendPayloadRaw } - : noteSendMeta - ? { - parentCanonical: noteSendMeta.parentCanonical, - amountPhi: noteSendMeta.amountPhi, - amountUsd: noteSendMeta.amountUsd, - childCanonical: noteSendMeta.childCanonical, - } - : null; const nextNonce = genNonce(); - const noteSendPayload = payloadBase - ? { - ...payloadBase, - parentCanonical: payloadBase.parentCanonical || noteSendMeta?.parentCanonical, - amountPhi: noteSendMeta?.amountPhi ?? payloadBase.amountPhi, - amountUsd: noteSendMeta?.amountUsd ?? payloadBase.amountUsd, - transferNonce: nextNonce, - } - : null; - if (noteSendPayload && "childCanonical" in noteSendPayload) { - delete (noteSendPayload as { childCanonical?: unknown }).childCanonical; - } + const payloadAmountScaled = + typeof payloadBase.amountPhiScaled === "string" ? payloadBase.amountPhiScaled.trim() : ""; + const amountPhiScaled = + payloadAmountScaled || + (typeof parentMeta.amountPhi === "number" && Number.isFinite(parentMeta.amountPhi) + ? toScaledBig(String(parentMeta.amountPhi)).toString() + : "0"); + const senderKaiPulse = + typeof payloadBase.senderKaiPulse === "number" && Number.isFinite(payloadBase.senderKaiPulse) + ? payloadBase.senderKaiPulse + : typeof payloadBase.lockedPulse === "number" && Number.isFinite(payloadBase.lockedPulse) + ? payloadBase.lockedPulse + : 0; + const senderStamp = + readRecordString(payloadBase, "senderStamp") ?? readRecordString(payloadBase, "valuationStamp") ?? ""; + const previousHeadRoot = readRecordString(payloadBase, "previousHeadRoot") ?? ""; + const leafSeed = stableStringify({ + parent: spentCanonical, + nonce: nextNonce, + amount: amountPhiScaled, + pulse: senderKaiPulse, + stamp: senderStamp, + root: previousHeadRoot, + }); + const transferLeafHashSend = (await sha256Hex(leafSeed)).toLowerCase(); + const childSeed = stableStringify({ + parent: spentCanonical, + nonce: nextNonce, + senderStamp, + senderKaiPulse, + prevHead: previousHeadRoot, + leafSend: transferLeafHashSend, + }); + const childCanonical = (await sha256Hex(childSeed)).toLowerCase(); + + const childNoteSendPayload: Record = { + ...payloadBase, + parentCanonical: spentCanonical, + amountPhi: parentMeta.amountPhi, + amountPhiScaled, + amountUsd: parentMeta.amountUsd, + transferNonce: nextNonce, + childCanonical, + senderKaiPulse, + senderStamp, + previousHeadRoot, + transferLeafHashSend, + }; + + const nonceSuffix = nextNonce ? `-${String(nextNonce).slice(0, 8)}` : ""; + const filename = `☤KAI-NOTE${nonceSuffix}.png`; - const nonce = nextNonce ? `-${nextNonce.slice(0, 8)}` : ""; - const filename = `kai-note${nonce}.png`; const png = await svgStringToPngBlob(noteSvgFromPng, 2400); - const noteSendJson = noteSendPayload ? JSON.stringify(noteSendPayload) : ""; + + const noteSendJson = JSON.stringify(childNoteSendPayload); + const entries = [ noteProofBundleJson ? { keyword: "phi_proof_bundle", text: noteProofBundleJson } : null, sharedReceipt?.bundleHash ? { keyword: "phi_bundle_hash", text: sharedReceipt.bundleHash } : null, sharedReceipt?.receiptHash ? { keyword: "phi_receipt_hash", text: sharedReceipt.receiptHash } : null, - noteSendJson ? { keyword: "phi_note_send", text: noteSendJson } : null, + { keyword: "phi_note_send", text: noteSendJson }, // ✅ NEW leaf embedded { keyword: "phi_note_svg", text: noteSvgFromPng }, - ].filter((entry): entry is { keyword: string; text: string } => Boolean(entry)); + ].filter((e): e is { keyword: string; text: string } => Boolean(e)); if (entries.length === 0) { triggerDownload(filename, png, "image/png"); - confirmNoteSend(); - return; + } else { + const bytes = new Uint8Array(await png.arrayBuffer()); + const enriched = insertPngTextChunks(bytes, entries); + const finalBlob = new Blob([enriched as BlobPart], { type: "image/png" }); + triggerDownload(filename, finalBlob, "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(filename, finalBlob, "image/png"); - confirmNoteSend(); - }, [confirmNoteSend, noteClaimed, noteProofBundleJson, noteSendMeta, noteSendPayloadRaw, noteSvgFromPng, sharedReceipt]); + await recordSend({ + parentCanonical: spentCanonical, + childCanonical, + amountPhiScaled, + kind: "note", + senderKaiPulse, + transferNonce: nextNonce, + senderStamp, + previousHeadRoot, + transferLeafHashSend, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : "Note download failed."; + setNotice(msg); + } finally { + noteDownloadBypassRef.current = false; + noteDownloadInFlightRef.current = false; + + // ──────────────────────────────────────────────────────────────── + // 3) CLAIM THE PARENT LEAF (the note you just used to mint the child) + // This is the key: parent shows CLAIMED, child stays UNCLAIMED. + // ──────────────────────────────────────────────────────────────── + confirmNoteSend({ + meta: parentMeta, + payloadRaw: parentPayloadRaw, + claimedPulse, + }); + } +}, [ + confirmNoteSend, + currentPulse, + effectiveNoteMeta, + noteClaimedFinal, + noteProofBundleJson, + noteSendMeta, + noteSendPayloadRaw, + noteSvgFromPng, + sharedReceipt, +]); + const onDownloadVerifiedCard = useCallback(async () => { if (!verifiedCardData) return; @@ -3160,6 +3694,13 @@ React.useEffect(() => { const activePanelTitle = panel === "inhale" ? "Inhale" : panel === "capsule" ? "Vessel" : panel === "proof" ? "Proof" : panel === "zk" ? "ZK" : "Audit"; + const detectedStatus = svgText.trim() + ? "Detected input: Sigil-Glyph (SVG) — Full attestation" + : isNoteUpload + ? "Detected input: Kai-Note (PNG) — Value note" + : sharedReceipt + ? "Detected input: Sigil-Seal (PNG) — Proof seal" + : ""; const onDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -3260,58 +3801,118 @@ React.useEffect(() => {
- +
+ +
+ Derived sovereign identifier +
+
{proofCapsule ? ( -
-
Proof
- {noteClaimStatus ? ( -
- {noteClaimStatus} -
- ) : null} -
- - - {isExhaleNoteUpload ? null : ( - - )} - {noteSvgFromPng && result.status === "ok" && !noteClaimed ? ( - - ) : null} - {isExhaleNoteUpload ? null : ( - - )} + {isExhaleNoteUpload ? null : ( + + )} + {isExhaleNoteUpload ? null : ( + + )} +
+ {canShowNotePreview || noteClaimStatus || (noteSvgFromPng && result.status === "ok" && !noteClaimed) ? ( +
+
+
☤Kai-Note (Legal Tender)
+ {noteClaimStatus ? ( +
+
+ {noteClaimStatus} +
+ {noteClaimed && noteClaimNonce ? ( +
+ rotation-seal {noteClaimNonceShort} + claimed pulse {noteClaimPulseLabel} + {noteClaimTransferHash ? hash {noteClaimHashShort} : null} + {noteClaimLeaderNonce && noteClaimLeaderNonce !== noteClaimNonce ? ( + leader {noteClaimLeaderShort} + ) : null} +
+ ) : null} +
+ ) : null} +
+
+ {canShowNotePreview ? ( + + ) : null} + {noteSvgFromPng && result.status === "ok" && !noteClaimedFinal ? ( + + ) : null} +
+
+ ) : null} - ) : null} @@ -3383,6 +3984,44 @@ React.useEffect(() => { ) : null} + +
+
+
☤Kairos Note Exhaler
+ +
+ +
+ {valuationPayload && svgText.trim() ? ( + + ) : ( +
Load and verify a sigil to render an exhale note.
+ )} +
+
+
+ {/* Body */}
@@ -3390,8 +4029,8 @@ React.useEffect(() => { {panel === "inhale" ? (
-
Inhale ΦKey
-
Tap to inhale a sealed ΦKey. Deep payloads open in Expanded Views.
+
Inhale: Sigil • Seal • Note
+
Drop Sigil-Glyph (SVG) or Sigil-Seal / Kai-Note (PNG) to derive Φ-Key.
@@ -3411,7 +4050,7 @@ React.useEffect(() => { ref={pngFileRef} className="vfile" type="file" - accept=".png,image/png,.pdf,application/pdf" + accept=".svg,image/svg+xml,.png,image/png,.pdf,application/pdf" onChange={(e) => { handleFiles(e.currentTarget.files); e.currentTarget.value = ""; @@ -3423,101 +4062,29 @@ React.useEffect(() => {
- +
Original minted files only — screenshots won’t verify.
+ {detectedStatus ?
{detectedStatus}
: null}
void runVerify()} disabled={busy} kind="primary" /> @@ -3532,29 +4099,30 @@ React.useEffect(() => { setSharedReceipt(null); setResult({ status: "idle" }); setNotice(""); + setNoteClaimedImmediate(false); + noteDownloadBypassRef.current = false; }} disabled={!svgText.trim() && !sharedReceipt} />
- {hasKASOwnerSig ? ( -
- - -
- ) : ( -
- -
- )} -
- - -
+{hasKASOwnerSig ? ( +
+ + + +
+) : ( +
+ + +
+)} +
-                        {svgPreview || "inhale a sealed ΦKey (.SVG) to begin…"}
+                        {svgPreview || "Inhale a Sigil-Glyph (SVG) or Sigil-Seal / Kai-Note (PNG) to begin…"}
                       
@@ -3569,7 +4137,7 @@ React.useEffect(() => {
diff --git a/src/utils/sendLedger.ts b/src/utils/sendLedger.ts index 9ffbf6cdd..ca433fa8d 100644 --- a/src/utils/sendLedger.ts +++ b/src/utils/sendLedger.ts @@ -4,6 +4,7 @@ // - Normalizes canonicals to lowercase // - Computes a deterministic record id (sha256 over a fixed-key payload) // - Broadcasts changes across tabs via BroadcastChannel +// - ✅ Mobile-safe invalidation: CustomEvent (same-tab) + storage bump (cross-tab) // - Exposes helpers for parent-spent and child-incoming base lookups import { sha256Hex } from "../components/VerifierStamper/crypto"; // adjust path if needed @@ -15,8 +16,12 @@ const LS_SENDS = "kai:sends:v1"; const MIGRATE_KEYS = ["sigil:send-ledger"]; // best-effort compatibility const BC_SENDS = "kai-sends-v1"; +// ✅ universal invalidation channels (mobile-safe) +const EVT_SENDS = "kai:sends:v1:changed"; +const LS_SENDS_BUMP = `${LS_SENDS}:bump`; + const hasWindow = typeof window !== "undefined"; -const canStorage = hasWindow && !!window.localStorage; +const canStorage = hasWindow && typeof window.localStorage !== "undefined"; const channel: BroadcastChannel | null = hasWindow && "BroadcastChannel" in window ? new BroadcastChannel(BC_SENDS) : null; @@ -24,17 +29,18 @@ const channel: BroadcastChannel | null = Types ────────────────────────── */ export type SendRecord = { - id: string; // deterministic (sha256 of fixed payload) - parentCanonical: string; // lowercase - childCanonical: string; // lowercase - amountPhiScaled: string; // BigInt string (Φ·10^18) - senderKaiPulse: number; // pulse at SEND - transferNonce: string; // nonce - senderStamp: string; // sender stamp - previousHeadRoot: string; // v14 prev-head root at SEND - transferLeafHashSend: string; // leaf hash (SEND side) - confirmed?: boolean; // set true on receive - createdAt: number; // ms epoch + id: string; // deterministic (sha256 of fixed payload) + parentCanonical: string; // lowercase + childCanonical: string; // lowercase + amountPhiScaled: string; // BigInt string (Φ·10^18) + kind?: "send" | "note"; // reserved spend type + senderKaiPulse: number; // pulse at SEND + transferNonce: string; // nonce + senderStamp: string; // sender stamp + previousHeadRoot: string; // v14 prev-head root at SEND + transferLeafHashSend: string; // leaf hash (SEND side) + confirmed?: boolean; // set true on receive + createdAt: number; // ms epoch }; type LedgerEvent = @@ -93,9 +99,7 @@ function migrateIfNeeded(): void { const rec = r || {}; const parentCanonical = lc((rec.parentCanonical ?? rec.parent ?? rec.p) as string); const childCanonical = lc((rec.childCanonical ?? rec.child ?? rec.c) as string); - const amountPhiScaled = coerceBigIntString( - (rec.amountPhiScaled ?? rec.amountScaled ?? rec.a) as string - ); + const amountPhiScaled = coerceBigIntString((rec.amountPhiScaled ?? rec.amountScaled ?? rec.a) as string); const senderKaiPulse = Number(rec.senderKaiPulse ?? rec.k ?? 0) || 0; const transferNonce = String(rec.transferNonce ?? rec.n ?? ""); const senderStamp = String(rec.senderStamp ?? rec.s ?? ""); @@ -111,6 +115,7 @@ function migrateIfNeeded(): void { parentCanonical, childCanonical, amountPhiScaled, + kind: "send", senderKaiPulse, transferNonce, senderStamp, @@ -162,6 +167,7 @@ function readAll(): SendRecord[] { parentCanonical: lc(rr.parentCanonical as string), childCanonical: lc(rr.childCanonical as string), amountPhiScaled: coerceBigIntString(rr.amountPhiScaled), + kind: rr.kind === "note" ? "note" : "send", senderKaiPulse: Number(rr.senderKaiPulse ?? 0) || 0, transferNonce: String(rr.transferNonce ?? ""), senderStamp: String(rr.senderStamp ?? ""), @@ -189,6 +195,7 @@ function writeAll(list: SendRecord[]) { parentCanonical: lc(r.parentCanonical), childCanonical: lc(r.childCanonical), amountPhiScaled: coerceBigIntString(r.amountPhiScaled), + kind: r.kind === "note" ? "note" : "send", createdAt: Number(r.createdAt || nowMs()) || nowMs(), })); localStorage.setItem(LS_SENDS, JSON.stringify(clean)); @@ -200,9 +207,7 @@ function writeAll(list: SendRecord[]) { /* ────────────────────────── Deterministic ID ────────────────────────── */ -export async function buildSendId( - rec: Omit -): Promise { +export async function buildSendId(rec: Omit): Promise { const payload = JSON.stringify({ p: lc(rec.parentCanonical), c: lc(rec.childCanonical), @@ -232,6 +237,7 @@ export async function recordSend(rec: Omit): Pro parentCanonical: lc(rec.parentCanonical), childCanonical: lc(rec.childCanonical), amountPhiScaled: coerceBigIntString(rec.amountPhiScaled), + kind: rec.kind === "note" ? "note" : "send", createdAt: nowMs(), }; writeAll([...list, row]); @@ -303,10 +309,7 @@ export function getSendsFor(parentCanonical: string): SendRecord[] { /** Sum of all Φ (scaled) exhaled from a parent canonical. */ export function getSpentScaledFor(parentCanonical: string): bigint { const rows = getSendsFor(parentCanonical); - return rows.reduce( - (acc, r) => (r.confirmed ? acc + BigInt(coerceBigIntString(r.amountPhiScaled)) : acc), - 0n - ); + return rows.reduce((acc, r) => (r.confirmed ? acc + BigInt(coerceBigIntString(r.amountPhiScaled)) : acc), 0n); } /** Sum of all Φ (scaled) reserved/exhaled from a parent canonical (confirmed + pending). */ @@ -315,6 +318,25 @@ export function getReservedScaledFor(parentCanonical: string): bigint { return rows.reduce((acc, r) => acc + BigInt(coerceBigIntString(r.amountPhiScaled)), 0n); } +/** Sum of all Φ (scaled) reserved/exhaled from a parent canonical by kind. */ +export function getReservedScaledForKind(parentCanonical: string, kind: "send" | "note" | "all" = "all"): bigint { + const rows = getSendsFor(parentCanonical); + return rows.reduce((acc, r) => { + if (kind !== "all" && (r.kind ?? "send") !== kind) return acc; + return acc + BigInt(coerceBigIntString(r.amountPhiScaled)); + }, 0n); +} + +/** Sum of all Φ (scaled) pending from a parent canonical by kind. */ +export function getPendingReservedScaledForKind(parentCanonical: string, kind: "send" | "note" | "all" = "all"): bigint { + const rows = getSendsFor(parentCanonical); + return rows.reduce((acc, r) => { + if (r.confirmed) return acc; + if (kind !== "all" && (r.kind ?? "send") !== kind) return acc; + return acc + BigInt(coerceBigIntString(r.amountPhiScaled)); + }, 0n); +} + /** * Incoming base Φ for a CHILD canonical. * Returns the most recent amount exhaled *to* this child, as scaled BigInt. @@ -336,34 +358,121 @@ export function getIncomingBaseScaledFor(childCanonical: string): bigint { return 0n; } -/** Fire-and-forget cross-tab notification. */ +/* ────────────────────────── + ✅ Mobile-safe invalidation +────────────────────────── */ + +/** + * Emit an invalidation event that works: + * - same-tab: CustomEvent (works everywhere, including iOS WKWebView) + * - cross-tab: localStorage bump (storage event), even if BroadcastChannel is missing/flaky + */ +function emitLocal(evt: LedgerEvent) { + if (!hasWindow) return; + + // Same-tab signal + try { + window.dispatchEvent(new CustomEvent(EVT_SENDS, { detail: evt })); + } catch { + /* ignore */ + } + + // Cross-tab signal (storage event fires in *other* tabs) + try { + if (canStorage) localStorage.setItem(LS_SENDS_BUMP, String(Date.now())); + } catch { + /* ignore */ + } +} + +/** Fire-and-forget notification. */ function safePost(evt: LedgerEvent) { try { channel?.postMessage(evt); } catch { /* ignore */ } + emitLocal(evt); } /** Simple invalidation listener (coarse). */ export function listen(cb: () => void): () => void { - if (!channel) return () => {}; - const onMsg = () => cb(); - channel.addEventListener("message", onMsg as EventListener); - return () => channel.removeEventListener("message", onMsg as EventListener); + if (!hasWindow) return () => {}; + + let scheduled = false; + const fire = () => { + if (scheduled) return; + scheduled = true; + queueMicrotask(() => { + scheduled = false; + cb(); + }); + }; + + const unsubs: Array<() => void> = []; + + // BroadcastChannel (if available) + if (channel) { + const onMsg = () => fire(); + channel.addEventListener("message", onMsg as EventListener); + unsubs.push(() => channel.removeEventListener("message", onMsg as EventListener)); + } + + // Same-tab CustomEvent (always) + const onEvt = () => fire(); + window.addEventListener(EVT_SENDS, onEvt as EventListener); + unsubs.push(() => window.removeEventListener(EVT_SENDS, onEvt as EventListener)); + + // Cross-tab storage fallback + const onStorage = (e: StorageEvent) => { + const k = e.key || ""; + if (k === LS_SENDS || k === LS_SENDS_BUMP || MIGRATE_KEYS.includes(k)) fire(); + }; + window.addEventListener("storage", onStorage); + unsubs.push(() => window.removeEventListener("storage", onStorage)); + + return () => unsubs.forEach((fn) => fn()); } /** Detailed listener (optional). */ export function listenDetailed(cb: (e: LedgerEvent) => void): () => void { - if (!channel) return () => {}; - const onMsg = (ev: MessageEvent) => { - const data = ev?.data; - if (!data || typeof data !== "object") return; - const t = (data as LedgerEvent).type; - if (t === "send:add" || t === "send:update") cb(data as LedgerEvent); + if (!hasWindow) return () => {}; + + const unsubs: Array<() => void> = []; + + // BroadcastChannel (if available) + if (channel) { + const onMsg = (ev: MessageEvent) => { + const data = ev?.data; + if (!data || typeof data !== "object") return; + const t = (data as LedgerEvent).type; + if (t === "send:add" || t === "send:update") cb(data as LedgerEvent); + }; + channel.addEventListener("message", onMsg as EventListener); + unsubs.push(() => channel.removeEventListener("message", onMsg as EventListener)); + } + + // Same-tab CustomEvent (always) + const onEvt = (ev: Event) => { + const detail = (ev as CustomEvent).detail as LedgerEvent | undefined; + if (!detail) return; + const t = detail.type; + if (t === "send:add" || t === "send:update") cb(detail); }; - channel.addEventListener("message", onMsg as EventListener); - return () => channel.removeEventListener("message", onMsg as EventListener); + window.addEventListener(EVT_SENDS, onEvt as EventListener); + unsubs.push(() => window.removeEventListener(EVT_SENDS, onEvt as EventListener)); + + // Cross-tab storage fallback (coarse) + const onStorage = (e: StorageEvent) => { + const k = e.key || ""; + if (k === LS_SENDS || k === LS_SENDS_BUMP || MIGRATE_KEYS.includes(k)) { + cb({ type: "send:update" }); + } + }; + window.addEventListener("storage", onStorage); + unsubs.push(() => window.removeEventListener("storage", onStorage)); + + return () => unsubs.forEach((fn) => fn()); } /* ────────────────────────── diff --git a/src/version.ts b/src/version.ts index aba4ea290..14850676b 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,7 +1,7 @@ // src/version.ts // Shared PWA version constants so the app shell, SW registration, and UI stay in sync. -export const BASE_APP_VERSION = "42.7.0"; // Canonical offline/PWA version +export const BASE_APP_VERSION = "43.0.0"; // Canonical offline/PWA version export const SW_VERSION_EVENT = "kairos:sw-version"; export const DEFAULT_APP_VERSION = BASE_APP_VERSION; // Keep in sync with public/sw.js const ENV_APP_VERSION =