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) && (
+
+ Attestation
+ Proof Of Breath™
+
+ )}
+
+ ))}
+
+ {/* Fallback: if we can't find Mint, keep Attestation at end */}
+ {!hasMint && (
+
+ Attestation
+ Proof Of Breath™
+
+ )}
+ >
+ );
+ })()}
+
-
- {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}
-
- ))}
-
-
- Attestation
- Proof Of Breath™
-
-
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 ? (
+
+ {isRendering ? "Minting…" : "Mint"}
+
+ ) : (
+
+ Minted
+ ·
+
+ ☤KAI {locked ? fPulse(locked.lockedPulse) : fPulse(displayPulse)}
+
+
+ )}
+
+
+
+
+ Φ
+ {phiParts.int}
+ {phiParts.frac}
+
+
+
≈ {fUsd(displayUsd)}
+
+
+
+
+
+ Print/PDF
+
+
+ SVG
+
+
+ PNG
+
+
+
+
+
-
-
VALUE
-
- Φ
- {phiParts.int}
- {phiParts.frac}
-
-
≈ {fUsd(displayUsd)}
-
-
-
-
- {!isLocked ? (
-
- {isRendering ? "Rendering…" : "Render — Lock Note"}
-
- ) : (
-
-
Locked
-
- ☤KAI {locked ? fPulse(locked.lockedPulse) : fPulse(displayPulse)} · stamp{" "}
- {form.valuationStamp || locked?.seal.stamp || "—"}
-
-
- )}
-
-
-
- Print / PDF
-
-
- Save SVG
-
-
- Save PNG
-
-
-
{/* 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 */}
+
+ setSendUnitSafe("phi")}
+ disabled={!isLocked}
+ aria-pressed={sendUnit === "phi"}
+ title="Enter Φ"
+ >
+ Φ
+
+
+ setSendUnitSafe("usd")}
+ disabled={!isLocked}
+ aria-pressed={sendUnit === "usd"}
+ title="Enter USD"
+ >
+ $
+
+
- {/* Send Amount */}
-
-
-
Send Amount
-
Committed when printing/saving exports.
-
-
-
- {/* Unit toggle */}
-
- setSendUnitSafe("phi")}
- disabled={!isLocked}
- aria-pressed={sendUnit === "phi"}
- title="Enter Φ"
- >
- Φ
-
- setSendUnitSafe("usd")}
- disabled={!isLocked}
- aria-pressed={sendUnit === "usd"}
- title="Enter USD"
- >
- $
-
-
+ {/* Amount input */}
+
+ {sendUnit === "phi" ? "Φ" : "$"}
+ {
+ 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}
-
-
void onShareReceipt()}>
- ➦
-
-
void onCopyReceipt()}>
- 💠
-
- {isExhaleNoteUpload ? null : (
-
void onSignVerification()}
- title={verificationSigLabel}
- aria-label={verificationSigLabel}
- disabled={!canSignVerification || verificationSigBusy}
- >
- ✍
-
- )}
- {noteSvgFromPng && result.status === "ok" && !noteClaimed ? (
-
- ⬇︎Φ
+
+
+
Proof
+
+ void onShareReceipt()}>
+ ➦
- ) : null}
- {isExhaleNoteUpload ? null : (
- void onDownloadVerifiedCard()}>
- ⬇
+ void onCopyReceipt()}>
+ 💠
- )}
+ {isExhaleNoteUpload ? null : (
+ void onSignVerification()}
+ title={verificationSigLabel}
+ aria-label={verificationSigLabel}
+ disabled={!canSignVerification || verificationSigBusy}
+ >
+
+
+
+
+ )}
+ {isExhaleNoteUpload ? null : (
+ void onDownloadVerifiedCard()}
+ title="Download proof PNG"
+ aria-label="Download proof PNG"
+ >
+
+
+
+
+ )}
+
+ {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(() => {
fileRef.current?.click()}
+ className={dragActive ? "vdrop vdrop--drag" : "vdrop"}
+ aria-label="Inhale Sigil"
+ title="Inhale Sigil"
+ onClick={() => pngFileRef.current?.click()}
>
- Inhale
-
-
- ΦKey
+
+ Inhale
+ {dragActive ? "Drop to Inhale" : "Sigil-Glyph (SVG) or Sigil-Seal / Kai-Note (PNG)"}
+
+ SVG
+ PNG
+
+
+
+
+ -KEY
-
pngFileRef.current?.click()}
- >
-
-
- {/* Outer ring */}
-
-
-
- {/* Ticks */}
- {Array.from({ length: 16 }).map((_, i) => {
- const a = (i * Math.PI * 2) / 16;
- const x1 = 32 + Math.cos(a) * 24;
- const y1 = 32 + Math.sin(a) * 24;
- const x2 = 32 + Math.cos(a) * 26;
- const y2 = 32 + Math.sin(a) * 26;
- return (
-
- );
- })}
-
- {/* Inner “breath” rosette */}
-
-
-
- {/* Center dot */}
-
-
-
-Proof Of Breath™
-
- Sigil-Seal
-
-
-
-
+
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(() => {
- Drag & drop ΦKey or receipt PNG anywhere in this panel
+ Drag & drop Sigil-Glyph (SVG) or Sigil-Seal / Kai-Note (PNG) anywhere in this panel
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 =