Embedding Kai Signature into your media…
; } - if (step === "share" && finalMedia && sealed && verifierData) { + if (activeStep === "share" && finalMedia && sealed && verifierData) { return (- Your memory is now verifiable as human-authored under this Φ-Key. Anyone can scan the QR or open the verifier link to confirm it - was sealed at this pulse under your sigil. + Your memory is now verifiable as human-authored under this Φ-Key. Anyone can scan the QR or open the verifier link to confirm it was sealed at + this pulse under your sigil.
Kai Portal
diff --git a/src/components/KaiVoh/styles/KaiVoh.css b/src/components/KaiVoh/styles/KaiVoh.css
index fb17006ce..5554a2b2c 100644
--- a/src/components/KaiVoh/styles/KaiVoh.css
+++ b/src/components/KaiVoh/styles/KaiVoh.css
@@ -1,6 +1,13 @@
-/* /components/KaiVoh/SocialConnector.css
- v3.1 — Atlantean Composer • Golden-Breath Timing (φ-synced)
- All motion = 5.236s or φ/Fibonacci derivatives.
+/* src/components/KaiVoh/styles/KaiVoh.css
+ (If your repo currently names this SocialConnector.css, replace that file
+ OR rename it to KaiVoh.css to match `import "./styles/KaiVoh.css";`)
+
+ v3.2 — Atlantean Composer • iOS-stable focus (no “jump” on input focus)
+ Goals:
+ - Keep the same look.
+ - Remove the two biggest iOS “typing feels like reload” triggers:
+ (1) transforms on focus (scroll jump inside fixed-body modal)
+ (2) filter animations on large layers (GPU thrash during keyboard open)
*/
:root {
@@ -20,8 +27,8 @@
--t-2: calc(var(--phi-breath) / 2); /* ≈ 2.618s */
/* Breathy easings */
- --ease-breath: cubic-bezier(.22,.61,.28,.99); /* gentle inhale/exhale */
- --ease-pulse: cubic-bezier(.15,.7,.25,1); /* quick harmonic pulse */
+ --ease-breath: cubic-bezier(.22,.61,.28,.99);
+ --ease-pulse: cubic-bezier(.15,.7,.25,1);
/* Palette — abyssal teal & auric highlights */
--ink-0: #000000;
@@ -78,33 +85,53 @@
radial-gradient(1200px 800px at 20% -10%, rgba(0, 238, 255, 0.08), transparent 60%),
radial-gradient(800px 600px at 120% 120%, rgba(255, 209, 102, 0.06), transparent 55%),
linear-gradient(180deg, var(--ink-3) 0%, var(--ink-1) 100%);
+
border: 1px solid var(--line-1);
border-radius: var(--radius-lg);
+
box-shadow: var(--shadow-ambient), inset 0 0 0 1px rgba(255, 255, 255, 0.04);
- backdrop-filter: blur(8px);
- overflow: clip;
+
+ /* iOS safety: blur only if supported; otherwise avoid heavy redraw */
+ background-clip: padding-box;
+ overflow: hidden;
+
+ -webkit-text-size-adjust: 100%;
+ text-size-adjust: 100%;
+
+ touch-action: auto;
+}
+
+@supports (backdrop-filter: blur(8px)) {
+ .social-connector-container {
+ backdrop-filter: blur(8px);
+ }
}
-/* Breathing auric ring — slow full breath */
+/* Breathing auric ring — slow full breath (opacity-only animation; no filter) */
.social-connector-container::before {
content: "";
position: absolute;
inset: -1px;
border-radius: calc(var(--radius-lg) + 1px);
pointer-events: none;
+
background:
radial-gradient(1200px 400px at 50% -20%, rgba(255, 209, 102, 0.12), transparent 60%),
radial-gradient(900px 300px at 50% 120%, rgba(0, 255, 230, 0.10), transparent 60%);
+
mask: linear-gradient(#000, #000) content-box, linear-gradient(#000, #000);
padding: 1px;
+
box-shadow: inset 0 0 0 1px rgba(255, 209, 102, 0.06);
+
animation: breathGlow var(--phi-breath-slow) var(--ease-breath) infinite;
+ will-change: opacity;
z-index: 0;
}
@keyframes breathGlow {
- 0%, 100% { opacity: 0.40; filter: blur(0.5px); }
- 50% { opacity: 0.70; filter: blur(1.2px); }
+ 0%, 100% { opacity: 0.40; }
+ 50% { opacity: 0.70; }
}
/* ===== Titles & Copy ====================================================== */
@@ -118,12 +145,15 @@
letter-spacing: 0.015em;
margin: 0.25rem 0 0.25rem;
text-shadow: 0 1px 0 rgba(0,0,0,0.35);
+
background: linear-gradient(90deg, var(--text-1), var(--aqua-2), var(--auric-1));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
+
animation: titleShimmer var(--phi-breath-fast) linear infinite;
background-size: 200%;
+ will-change: background-position;
}
@keyframes titleShimmer {
@@ -199,16 +229,25 @@
width: 100%;
padding: 0.78rem 1rem;
border-radius: var(--radius-md);
+
background: linear-gradient(180deg, rgba(10,20,30,0.66), rgba(8,14,20,0.72));
color: var(--text-1);
+
border: 1px solid var(--line-2);
outline: none;
+
transition:
border-color var(--t-34) var(--ease-breath),
box-shadow var(--t-34) var(--ease-breath),
- background var(--t-34) var(--ease-breath),
- transform var(--t-34) var(--ease-breath);
+ background var(--t-34) var(--ease-breath);
+
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.02);
+
+ /* extra iOS safety: don’t let focus cause layout scroll jumps */
+ transform: none !important;
+
+ -webkit-text-size-adjust: 100%;
+ text-size-adjust: 100%;
}
.social-connector-container .composer-textarea { min-height: 100px; resize: vertical; }
@@ -217,7 +256,7 @@
.social-connector-container .composer-textarea:focus {
border-color: rgba(0,255,230,0.38);
box-shadow: var(--glow-aqua-md), inset 0 0 0 1px rgba(255,255,255,0.04), var(--focus-ring);
- transform: translateY(-0.5px);
+ /* IMPORTANT: no translateY on focus */
}
.social-connector-container .composer-input.warn {
@@ -424,7 +463,7 @@
.social-connector-container .dz-sub { color: var(--text-3); font-size: 0.9rem; }
.social-connector-container .dz-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.4rem; }
-/* Pills (reusable buttons) */
+/* Pills */
.social-connector-container .pill {
display: inline-flex;
align-items: center;
@@ -465,7 +504,7 @@
}
.social-connector-container .pill.icon-only { width: 44px; padding: 0; }
-/* Inline icons (SVG) */
+/* Inline icons */
.social-connector-container .ico {
width: 20px;
height: 20px;
@@ -551,14 +590,11 @@
box-shadow: 0 0 8px rgba(0,255,230,0.35);
animation: phiBlink var(--phi-breath) var(--ease-breath) infinite;
}
-@keyframes phiBlink {
- 0%, 100% { opacity: 0.8; }
- 50% { opacity: 0.35; }
-}
+@keyframes phiBlink { 0%, 100% { opacity: 0.8; } 50% { opacity: 0.35; } }
.social-connector-container .id-text { color: var(--text-1); font-weight: 700; }
.social-connector-container .id-sub { color: var(--text-3); }
-/* ===== Story Recorder (Modal + Preview) =================================== */
+/* ===== Story Recorder ===================================================== */
.social-connector-container .story-actions {
display: flex;
@@ -642,7 +678,7 @@
height: 100%; width: 0%;
background: linear-gradient(90deg, #ff7b7b, #ffd166, #00ffd5);
box-shadow: 0 0 14px rgba(255, 209, 102, 0.22);
- transition: width var(--t-21) linear; /* Kai-aligned micro progress */
+ transition: width var(--t-21) linear;
}
.social-connector-container .story-btn {
@@ -661,12 +697,6 @@
.social-connector-container .story-btn.primary { background: linear-gradient(135deg, #15616d, #0b3943); color: #e7ffff; }
.social-connector-container .story-foot { display: flex; align-items: center; gap: 0.5rem; justify-content: flex-end; padding: 0.75rem 0.9rem; border-top: 1px solid rgba(255,255,255,0.06); }
-/* ===== Icon Aura ========================================================== */
-
-.breath-glow {
- filter: drop-shadow(0 0 10px rgba(0,255,230,0.22)) drop-shadow(0 0 18px rgba(255,209,102,0.10));
-}
-
/* ===== Utility ============================================================ */
.social-connector-container .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; }
@@ -688,10 +718,9 @@
transition-duration: 0ms !important;
}
}
+
/* ─────────────────────────────────────────────────────────────
- SocialConnector — Kai-Sigil Proof Sharing
- - Proof caption preview, metadata, share rows
- - Uses kv-btn tokens from KaiVohApp.css
+ SocialConnector — Kai-Sigil Proof Sharing (unchanged styling)
───────────────────────────────────────────────────────────── */
.kv-social-connector {
@@ -706,13 +735,7 @@
gap: 14px;
}
-/* Header */
-
-.kv-social-header {
- display: flex;
- flex-direction: column;
- gap: 4px;
-}
+.kv-social-header { display: flex; flex-direction: column; gap: 4px; }
.kv-social-title {
font-size: 16px;
@@ -721,12 +744,7 @@
text-transform: uppercase;
}
-.kv-social-subtitle {
- font-size: 13px;
- color: var(--kv-ink-soft, #b7c7dd);
-}
-
-/* Proof block */
+.kv-social-subtitle { font-size: 13px; color: var(--kv-ink-soft, #b7c7dd); }
.kv-social-proof {
display: grid;
@@ -765,8 +783,6 @@
word-wrap: break-word;
}
-/* Proof meta */
-
.kv-social-proof-meta {
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.16);
@@ -778,12 +794,7 @@
font-size: 12px;
}
-.kv-proof-meta-row {
- display: flex;
- align-items: baseline;
- justify-content: space-between;
- gap: 8px;
-}
+.kv-proof-meta-row { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.kv-proof-meta-label {
color: var(--kv-ink-muted, #8b9bb2);
@@ -797,56 +808,18 @@
text-align: right;
}
-/* Actions */
-
-.kv-social-actions {
- display: flex;
- flex-direction: column;
- gap: 10px;
-}
-
-.kv-social-row {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
-}
-
-.kv-social-row--primary {
- justify-content: flex-start;
-}
-
-.kv-social-row--grid {
- flex-wrap: wrap;
-}
-
-.kv-social-row--secondary {
- justify-content: flex-start;
-}
+.kv-social-actions { display: flex; flex-direction: column; gap: 10px; }
-/* Status */
-
-.kv-social-status {
- font-size: 12px;
- color: var(--kv-ink-soft, #b7c7dd);
- margin-top: 4px;
-}
+.kv-social-row { display: flex; flex-wrap: wrap; gap: 8px; }
+.kv-social-row--primary { justify-content: flex-start; }
+.kv-social-row--grid { flex-wrap: wrap; }
+.kv-social-row--secondary { justify-content: flex-start; }
-/* Responsive */
+.kv-social-status { font-size: 12px; color: var(--kv-ink-soft, #b7c7dd); margin-top: 4px; }
@media (max-width: 768px) {
- .kv-social-connector {
- padding: 12px 10px 14px;
- }
-
- .kv-social-proof {
- grid-template-columns: minmax(0, 1fr);
- }
-
- .kv-social-row {
- flex-direction: column;
- }
-
- .kv-social-row .kv-btn {
- width: 100%;
- }
+ .kv-social-connector { padding: 12px 10px 14px; }
+ .kv-social-proof { grid-template-columns: minmax(0, 1fr); }
+ .kv-social-row { flex-direction: column; }
+ .kv-social-row .kv-btn { width: 100%; }
}
diff --git a/src/components/KaiVoh/styles/KaiVohApp.css b/src/components/KaiVoh/styles/KaiVohApp.css
index b6e7bc0ff..f60f945d4 100644
--- a/src/components/KaiVoh/styles/KaiVohApp.css
+++ b/src/components/KaiVoh/styles/KaiVohApp.css
@@ -1,8 +1,18 @@
-/* ──────────────────────────────────────────────────────────────
+/* src/components/KaiVoh/styles/KaiVohApp.css
KaiVohApp.css — Sigil Posting Hub (Breath / Φ aligned)
- Atlantean glass, golden-breath entrainment, sovereign clarity.
- NO Tailwind required — everything is self-contained + responsive.
- ────────────────────────────────────────────────────────────── */
+ v5.2.3 — iOS KEYBOARD-SAFE (no transform/filter breathing on large shells)
+
+ WHY THIS CHANGE:
+ On iOS inside a fixed-body modal, continuous animations that change:
+ - transform (translate/scale) on large containers, OR
+ - filter (blur) on large layers
+ can cause WebKit to re-composite during keyboard open + typing, which looks like “reload” / flicker / jump.
+
+ FIX:
+ - Keep your look, but make all “breathing” animations opacity/box-shadow only.
+ - Remove transform-based breathing on the big shells/cards and filter blur animation.
+ - Keep hover transforms (desktop) but stop perpetual transform animations.
+*/
:root {
--kv-breath: 5.236s;
@@ -26,73 +36,58 @@
}
/* ──────────────────────────────────────────────────────────────
- Breath keyframes (φ-synced)
+ Breath keyframes (φ-synced) — iOS safe (no transform/filter)
────────────────────────────────────────────────────────────── */
@keyframes kv-shell-breathe {
- 0% {
- opacity: 0.55;
- transform: translate3d(0, 0, 0) scale(1);
- }
- 50% {
- opacity: 0.8;
- transform: translate3d(0, -1px, 0) scale(1.005);
- }
- 100% {
- opacity: 1;
- transform: translate3d(0, -2px, 0) scale(1.01);
- }
+ 0% { opacity: 0.55; }
+ 50% { opacity: 0.80; }
+ 100% { opacity: 1.00; }
}
@keyframes kv-halo-breathe {
- 0% {
- opacity: 0.4;
- filter: blur(0px);
- }
- 100% {
- opacity: 0.9;
- filter: blur(3px);
- }
+ 0% { opacity: 0.35; }
+ 100% { opacity: 0.85; }
}
@keyframes kv-pulse-glow {
0% {
- text-shadow: 0 0 10px rgba(56, 189, 248, 0.6),
+ text-shadow:
+ 0 0 10px rgba(56, 189, 248, 0.6),
0 0 0 rgba(74, 222, 128, 0);
}
100% {
- text-shadow: 0 0 18px rgba(56, 189, 248, 0.9),
+ text-shadow:
+ 0 0 18px rgba(56, 189, 248, 0.9),
0 0 30px rgba(74, 222, 128, 0.6);
}
}
+/* Step highlight — box-shadow only (no translateY animation) */
@keyframes kv-step-glide {
0% {
box-shadow:
0 0 0 0 rgba(34, 211, 238, 0.0),
0 6px 12px rgba(15, 23, 42, 0.9);
- transform: translateY(0);
}
100% {
box-shadow:
0 0 0 1px rgba(34, 211, 238, 0.6),
0 8px 18px rgba(15, 23, 42, 0.95);
- transform: translateY(-1px);
}
}
+/* Primary button “breath” — box-shadow only (no translateY animation) */
@keyframes kv-button-breathe {
0% {
box-shadow:
0 10px 26px rgba(15, 23, 42, 0.95),
0 0 0 1px rgba(15, 23, 42, 0.9) inset;
- transform: translateY(0);
}
100% {
box-shadow:
0 18px 40px rgba(15, 23, 42, 0.98),
0 0 0 1px rgba(15, 23, 42, 0.9) inset;
- transform: translateY(-1px);
}
}
@@ -109,7 +104,7 @@
}
}
-/* Motion safety: soften for users who prefer reduced motion */
+/* Motion safety */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.001ms !important;
@@ -134,6 +129,13 @@
display: flex;
flex-direction: column;
gap: 1rem;
+
+ /* iOS stability: avoid forcing compositor for the whole shell */
+ transform: none;
+ -webkit-transform: none;
+ backface-visibility: visible;
+ -webkit-backface-visibility: visible;
+ will-change: auto;
}
/* Cosmic gradient + phi-field aura behind the whole KaiVoh stack */
@@ -144,31 +146,15 @@
inset: -40px -24px;
z-index: -2;
background:
- radial-gradient(
- 80% 120% at 50% 0%,
- rgba(34, 211, 238, 0.22),
- transparent 70%
- ),
- radial-gradient(
- 80% 120% at 0% 100%,
- rgba(139, 92, 246, 0.23),
- transparent 70%
- ),
- radial-gradient(
- 60% 100% at 100% 100%,
- rgba(250, 204, 21, 0.16),
- transparent 70%
- ),
- radial-gradient(
- 120% 120% at 50% 50%,
- rgba(15, 23, 42, 0.98),
- #020617
- );
+ radial-gradient(80% 120% at 50% 0%, rgba(34, 211, 238, 0.22), transparent 70%),
+ radial-gradient(80% 120% at 0% 100%, rgba(139, 92, 246, 0.23), transparent 70%),
+ radial-gradient(60% 100% at 100% 100%, rgba(250, 204, 21, 0.16), transparent 70%),
+ radial-gradient(120% 120% at 50% 50%, rgba(15, 23, 42, 0.98), #020617);
opacity: 0.98;
filter: saturate(1.08);
}
-/* Subtle breathing halo overlay */
+/* Subtle breathing halo overlay (opacity only) */
.kai-voh-login-shell::after,
.kai-voh-app-shell::after {
content: "";
@@ -212,23 +198,22 @@
border: 1px solid rgba(148, 163, 184, 0.55);
padding: 1.5rem 1.6rem;
background:
- linear-gradient(
- 145deg,
- rgba(15, 23, 42, 0.98),
- rgba(15, 23, 42, 0.9)
- )
- padding-box;
+ linear-gradient(145deg, rgba(15, 23, 42, 0.98), rgba(15, 23, 42, 0.9)) padding-box;
box-shadow:
0 18px 60px rgba(15, 23, 42, 0.95),
0 0 0 1px rgba(15, 23, 42, 0.9) inset;
overflow: hidden;
+
-webkit-backdrop-filter: blur(18px) saturate(1.15);
backdrop-filter: blur(18px) saturate(1.15);
- transform: translate3d(0, 0, 0);
- -webkit-transform: translate3d(0, 0, 0);
- backface-visibility: hidden;
- -webkit-backface-visibility: hidden;
- will-change: transform, opacity;
+
+ /* iOS stability: no forced translate3d */
+ transform: none;
+ -webkit-transform: none;
+ backface-visibility: visible;
+ -webkit-backface-visibility: visible;
+ will-change: auto;
+
background-color: rgba(15, 23, 42, 0.94);
}
@@ -240,37 +225,23 @@
pointer-events: none;
opacity: 0.12;
background-image:
- linear-gradient(
- 90deg,
- rgba(148, 163, 184, 0.22) 1px,
- transparent 1px
- ),
- linear-gradient(
- 0deg,
- rgba(148, 163, 184, 0.2) 1px,
- transparent 1px
- );
+ linear-gradient(90deg, rgba(148, 163, 184, 0.22) 1px, transparent 1px),
+ linear-gradient(0deg, rgba(148, 163, 184, 0.2) 1px, transparent 1px);
background-size:
calc(100% / var(--kv-phi)) 100%,
100% calc(100% / var(--kv-phi));
mix-blend-mode: screen;
}
-/* Top halo */
+/* Top halo (opacity-only breathe; no filter blur) */
.kv-main-card::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
- background:
- radial-gradient(
- 120% 120% at 50% 0%,
- rgba(34, 211, 238, 0.16),
- transparent 60%
- );
+ background: radial-gradient(120% 120% at 50% 0%, rgba(34, 211, 238, 0.16), transparent 60%);
opacity: 0.7;
- animation: kv-halo-breathe calc(var(--kv-breath) * 0.85)
- ease-in-out infinite alternate;
+ animation: kv-halo-breathe calc(var(--kv-breath) * 0.85) ease-in-out infinite alternate;
}
/* Error text in card */
@@ -282,7 +253,7 @@
}
/* ──────────────────────────────────────────────────────────────
- Session HUD (Φ-Key / Pulse / Chakra / Steps / Actions)
+ Session HUD
────────────────────────────────────────────────────────────── */
.kv-session-hud {
@@ -291,16 +262,8 @@
border: 1px solid var(--kv-border-soft);
padding: 0.75rem 1rem 0.85rem;
background:
- radial-gradient(
- 140% 160% at 0% 0%,
- rgba(148, 163, 184, 0.22),
- transparent 60%
- ),
- radial-gradient(
- 180% 200% at 100% 100%,
- rgba(15, 23, 42, 0.88),
- rgba(15, 23, 42, 0.98)
- );
+ radial-gradient(140% 160% at 0% 0%, rgba(148, 163, 184, 0.22), transparent 60%),
+ radial-gradient(180% 200% at 100% 100%, rgba(15, 23, 42, 0.88), rgba(15, 23, 42, 0.98));
display: flex;
flex-direction: column;
gap: 0.65rem;
@@ -308,35 +271,22 @@
0 18px 50px rgba(15, 23, 42, 0.9),
0 0 0 1px rgba(15, 23, 42, 0.9) inset;
overflow: hidden;
+
-webkit-backdrop-filter: blur(16px) saturate(1.2);
backdrop-filter: blur(16px) saturate(1.2);
- transform: translate3d(0, 0, 0);
- -webkit-transform: translate3d(0, 0, 0);
- backface-visibility: hidden;
- -webkit-backface-visibility: hidden;
- will-change: transform, opacity;
- background-color: rgba(15, 23, 42, 0.94);
-}
-
-/* Chakra aura is layered via kv-chakra-* ::after, see below */
-.kv-session-main {
- display: flex;
- flex-direction: column;
- gap: 0.45rem;
-}
+ transform: none;
+ -webkit-transform: none;
+ backface-visibility: visible;
+ -webkit-backface-visibility: visible;
+ will-change: auto;
-.kv-session-header-row {
- display: flex;
- flex-direction: column;
- gap: 0.35rem;
+ background-color: rgba(15, 23, 42, 0.94);
}
-.kv-session-title-block {
- display: flex;
- flex-direction: column;
- gap: 0.24rem;
-}
+.kv-session-main { display: flex; flex-direction: column; gap: 0.45rem; }
+.kv-session-header-row { display: flex; flex-direction: column; gap: 0.35rem; }
+.kv-session-title-block { display: flex; flex-direction: column; gap: 0.24rem; }
.kv-session-kicker {
font-size: 0.66rem;
@@ -353,11 +303,7 @@
color: rgba(226, 232, 240, 0.9);
}
-.kv-meta-item {
- display: inline-flex;
- align-items: baseline;
- gap: 0.25rem;
-}
+.kv-meta-item { display: inline-flex; align-items: baseline; gap: 0.25rem; }
.kv-meta-label {
font-size: 0.65rem;
@@ -367,29 +313,19 @@
}
.kv-meta-value {
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
- "Liberation Mono", "Courier New", monospace;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.74rem;
font-weight: 600;
}
-.kv-meta-phikey .kv-meta-value {
- letter-spacing: 0.03em;
-}
-
-.kv-meta-activity .kv-meta-value {
- color: var(--kv-emerald);
-}
+.kv-meta-phikey .kv-meta-value { letter-spacing: 0.03em; }
+.kv-meta-activity .kv-meta-value { color: var(--kv-emerald); }
.kv-meta-divider {
width: 1px;
height: 0.9rem;
align-self: center;
- background: linear-gradient(
- to bottom,
- rgba(148, 163, 184, 0.4),
- rgba(148, 163, 184, 0.1)
- );
+ background: linear-gradient(to bottom, rgba(148, 163, 184, 0.4), rgba(148, 163, 184, 0.1));
}
.kv-session-status-block {
@@ -409,22 +345,19 @@
backdrop-filter: blur(8px);
text-transform: uppercase;
letter-spacing: 0.14em;
- transform: translate3d(0, 0, 0);
- -webkit-transform: translate3d(0, 0, 0);
- backface-visibility: hidden;
- -webkit-backface-visibility: hidden;
- will-change: transform, opacity;
+
+ transform: none;
+ -webkit-transform: none;
+ backface-visibility: visible;
+ -webkit-backface-visibility: visible;
+ will-change: auto;
}
.kv-accounts-pill--ok {
border-color: rgba(52, 211, 153, 0.8);
color: rgba(187, 247, 208, 0.95);
background:
- radial-gradient(
- 140% 160% at 0% 0%,
- rgba(16, 185, 129, 0.18),
- transparent 60%
- ),
+ radial-gradient(140% 160% at 0% 0%, rgba(16, 185, 129, 0.18), transparent 60%),
rgba(15, 23, 42, 0.9);
}
@@ -432,11 +365,7 @@
border-color: rgba(251, 191, 36, 0.85);
color: rgba(254, 243, 199, 0.95);
background:
- radial-gradient(
- 140% 160% at 0% 0%,
- rgba(245, 158, 11, 0.22),
- transparent 60%
- ),
+ radial-gradient(140% 160% at 0% 0%, rgba(245, 158, 11, 0.22), transparent 60%),
rgba(15, 23, 42, 0.9);
}
@@ -465,8 +394,7 @@
}
.kv-live-value {
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
- "Liberation Mono", "Courier New", monospace;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-weight: 600;
color: rgba(226, 232, 255, 0.98);
animation: kv-pulse-glow var(--kv-breath) ease-in-out infinite alternate;
@@ -478,12 +406,8 @@
color: #6ee7b7;
}
-/* Row for steps */
-.kv-session-steps-row {
- margin-top: 0.05rem;
-}
+.kv-session-steps-row { margin-top: 0.05rem; }
-/* Actions on the right (stacked on mobile) */
.kv-session-actions {
display: flex;
flex-direction: row;
@@ -494,54 +418,19 @@
/* Desktop layout tuning */
@media (min-width: 768px) {
- .kv-session-hud {
- flex-direction: row;
- align-items: flex-start;
- justify-content: space-between;
- }
-
- .kv-session-main {
- flex: 1 1 auto;
- padding-right: 0.75rem;
- }
-
- .kv-session-actions {
- flex-direction: column;
- align-items: flex-end;
- justify-content: center;
- min-width: 170px;
- }
-
- .kv-session-header-row {
- flex-direction: row;
- align-items: center;
- justify-content: space-between;
- }
-
- .kv-session-status-block {
- justify-content: flex-end;
- padding-left: 1.5rem;
- align-items: flex-end;
- text-align: right;
- }
+ .kv-session-hud { flex-direction: row; align-items: flex-start; justify-content: space-between; }
+ .kv-session-main { flex: 1 1 auto; padding-right: 0.75rem; }
+ .kv-session-actions { flex-direction: column; align-items: flex-end; justify-content: center; min-width: 170px; }
+ .kv-session-header-row { flex-direction: row; align-items: center; justify-content: space-between; }
+ .kv-session-status-block { justify-content: flex-end; padding-left: 1.5rem; align-items: flex-end; text-align: right; }
}
/* ──────────────────────────────────────────────────────────────
Steps / flow indicator
────────────────────────────────────────────────────────────── */
-.kv-steps {
- display: flex;
- flex-wrap: wrap;
- gap: 0.5rem;
- align-items: center;
-}
-
-.kv-step {
- display: flex;
- align-items: center;
- gap: 0.35rem;
-}
+.kv-steps { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
+.kv-step { display: flex; align-items: center; gap: 0.35rem; }
.kv-step-chip {
display: inline-flex;
@@ -560,25 +449,17 @@
.kv-step-index {
font-size: 0.62rem;
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
- "Liberation Mono", "Courier New", monospace;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
opacity: 0.8;
}
-.kv-step-label {
- white-space: nowrap;
-}
+.kv-step-label { white-space: nowrap; }
-/* Active / done states */
.kv-step-chip--done {
border-color: rgba(52, 211, 153, 0.9);
color: rgba(187, 247, 208, 0.95);
background:
- radial-gradient(
- 120% 120% at 0% 0%,
- rgba(34, 197, 94, 0.25),
- transparent 60%
- ),
+ radial-gradient(120% 120% at 0% 0%, rgba(34, 197, 94, 0.25), transparent 60%),
rgba(15, 23, 42, 0.95);
}
@@ -586,29 +467,19 @@
border-color: rgba(34, 211, 238, 0.95);
color: rgba(224, 242, 254, 0.98);
background:
- radial-gradient(
- 120% 120% at 0% 0%,
- rgba(56, 189, 248, 0.32),
- transparent 60%
- ),
+ radial-gradient(120% 120% at 0% 0%, rgba(56, 189, 248, 0.32), transparent 60%),
rgba(15, 23, 42, 0.98);
- animation: kv-step-glide calc(var(--kv-breath) * 0.6)
- ease-in-out infinite alternate;
+ animation: kv-step-glide calc(var(--kv-breath) * 0.6) ease-in-out infinite alternate;
}
-/* Rail between steps */
.kv-step-rail {
width: 22px;
height: 1px;
- background: linear-gradient(
- to right,
- rgba(148, 163, 184, 0.35),
- rgba(148, 163, 184, 0.12)
- );
+ background: linear-gradient(to right, rgba(148, 163, 184, 0.35), rgba(148, 163, 184, 0.12));
}
/* ──────────────────────────────────────────────────────────────
- Buttons (KaiVoh actions)
+ Buttons
────────────────────────────────────────────────────────────── */
.kv-btn {
@@ -639,33 +510,20 @@
.kv-btn-primary {
border-color: rgba(34, 211, 238, 0.9);
- background: linear-gradient(
- 135deg,
- rgba(56, 189, 248, 0.98),
- rgba(45, 212, 191, 0.96)
- );
+ background: linear-gradient(135deg, rgba(56, 189, 248, 0.98), rgba(45, 212, 191, 0.96));
color: #020617;
- animation: kv-button-breathe calc(var(--kv-breath) * 0.75)
- ease-in-out infinite alternate;
+ animation: kv-button-breathe calc(var(--kv-breath) * 0.75) ease-in-out infinite alternate;
}
.kv-btn-ghost {
border-color: rgba(148, 163, 184, 0.85);
background:
- radial-gradient(
- 120% 120% at 0% 0%,
- rgba(148, 163, 184, 0.15),
- transparent 60%
- ),
+ radial-gradient(120% 120% at 0% 0%, rgba(148, 163, 184, 0.15), transparent 60%),
rgba(15, 23, 42, 0.96);
color: rgba(226, 232, 240, 0.96);
}
-.kv-btn-wide {
- margin-top: 1rem;
- align-self: center;
- min-width: 220px;
-}
+.kv-btn-wide { margin-top: 1rem; align-self: center; min-width: 220px; }
.kv-btn:hover {
transform: translateY(-1px);
@@ -682,7 +540,7 @@
}
/* ──────────────────────────────────────────────────────────────
- Activity strip (recent sealed posts)
+ Activity strip
────────────────────────────────────────────────────────────── */
.kv-activity {
@@ -698,16 +556,17 @@
overflow: hidden;
-webkit-backdrop-filter: blur(14px) saturate(1.18);
backdrop-filter: blur(14px) saturate(1.18);
- transform: translate3d(0, 0, 0);
- -webkit-transform: translate3d(0, 0, 0);
- backface-visibility: hidden;
- -webkit-backface-visibility: hidden;
- will-change: transform, opacity;
- animation: kv-activity-glow calc(var(--kv-breath) * 1.25)
- ease-in-out infinite alternate;
+
+ transform: none;
+ -webkit-transform: none;
+ backface-visibility: visible;
+ -webkit-backface-visibility: visible;
+ will-change: auto;
+
+ animation: kv-activity-glow calc(var(--kv-breath) * 1.25) ease-in-out infinite alternate;
}
-/* Stable glass on Android/WebView: keep the look without blur flashes */
+/* Stable glass on Android/WebView */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.kv-main-card,
.kv-session-hud,
@@ -719,46 +578,23 @@
}
.kv-main-card {
- background:
- linear-gradient(
- 145deg,
- rgba(15, 23, 42, 0.98),
- rgba(15, 23, 42, 0.92)
- )
- padding-box;
+ background: linear-gradient(145deg, rgba(15, 23, 42, 0.98), rgba(15, 23, 42, 0.92)) padding-box;
}
.kv-session-hud {
background:
- radial-gradient(
- 140% 160% at 0% 0%,
- rgba(148, 163, 184, 0.16),
- transparent 60%
- ),
- radial-gradient(
- 180% 200% at 100% 100%,
- rgba(15, 23, 42, 0.9),
- rgba(15, 23, 42, 1)
- );
+ radial-gradient(140% 160% at 0% 0%, rgba(148, 163, 184, 0.16), transparent 60%),
+ radial-gradient(180% 200% at 100% 100%, rgba(15, 23, 42, 0.9), rgba(15, 23, 42, 1));
}
.kv-activity {
background:
- radial-gradient(
- 120% 120% at 0% 0%,
- rgba(34, 211, 238, 0.12),
- transparent 65%
- ),
- radial-gradient(
- 120% 120% at 100% 100%,
- rgba(139, 92, 246, 0.12),
- transparent 65%
- ),
+ radial-gradient(120% 120% at 0% 0%, rgba(34, 211, 238, 0.12), transparent 65%),
+ radial-gradient(120% 120% at 100% 100%, rgba(139, 92, 246, 0.12), transparent 65%),
rgba(15, 23, 42, 0.96);
}
}
-/* Coarse pointers (Android) — force compositing to avoid flicker */
@media (pointer: coarse) {
.kv-main-card::before,
.kv-main-card::after,
@@ -766,14 +602,6 @@
.kv-activity::before {
mix-blend-mode: normal;
}
-
- .kv-main-card,
- .kv-session-hud,
- .kv-activity,
- .kv-accounts-pill {
- content-visibility: visible;
- contain-intrinsic-size: auto;
- }
}
.kv-activity::before {
@@ -782,16 +610,8 @@
inset: 0;
pointer-events: none;
background:
- radial-gradient(
- 120% 120% at 0% 0%,
- rgba(34, 211, 238, 0.18),
- transparent 65%
- ),
- radial-gradient(
- 120% 120% at 100% 100%,
- rgba(139, 92, 246, 0.18),
- transparent 65%
- );
+ radial-gradient(120% 120% at 0% 0%, rgba(34, 211, 238, 0.18), transparent 65%),
+ radial-gradient(120% 120% at 100% 100%, rgba(139, 92, 246, 0.18), transparent 65%);
opacity: 0.75;
mix-blend-mode: screen;
}
@@ -807,24 +627,14 @@
margin-bottom: 0.55rem;
}
-.kv-activity-title {
- text-transform: uppercase;
- letter-spacing: 0.18em;
-}
+.kv-activity-title { text-transform: uppercase; letter-spacing: 0.18em; }
.kv-activity-count {
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
- "Liberation Mono", "Courier New", monospace;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.66rem;
}
-.kv-activity-list {
- position: relative;
- z-index: 1;
- display: flex;
- flex-direction: column;
- gap: 0.35rem;
-}
+.kv-activity-list { position: relative; z-index: 1; display: flex; flex-direction: column; gap: 0.35rem; }
.kv-activity-item {
display: flex;
@@ -837,28 +647,16 @@
border: 1px solid rgba(51, 65, 85, 0.9);
}
-.kv-activity-item-main {
- display: flex;
- flex-direction: column;
- gap: 0.1rem;
-}
+.kv-activity-item-main { display: flex; flex-direction: column; gap: 0.1rem; }
-.kv-activity-platform {
- font-size: 0.8rem;
- font-weight: 600;
- color: rgba(226, 232, 240, 0.98);
-}
+.kv-activity-platform { font-size: 0.8rem; font-weight: 600; color: rgba(226, 232, 240, 0.98); }
.kv-activity-pulse {
font-size: 0.7rem;
color: rgba(148, 163, 184, 0.95);
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
- "Liberation Mono", "Courier New", monospace;
-}
-
-.kv-activity-pulse span {
- color: rgba(226, 232, 240, 0.96);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
+.kv-activity-pulse span { color: rgba(226, 232, 240, 0.96); }
.kv-activity-link {
max-width: 9.5rem;
@@ -869,57 +667,20 @@
overflow: hidden;
text-overflow: ellipsis;
}
+.kv-activity-link:hover { text-decoration: underline; text-underline-offset: 2px; }
-.kv-activity-link:hover {
- text-decoration: underline;
- text-underline-offset: 2px;
-}
+/* Flow states */
+.kv-connect-step { display: flex; flex-direction: column; gap: 0.75rem; align-items: center; }
-/* ──────────────────────────────────────────────────────────────
- Flow states
- ────────────────────────────────────────────────────────────── */
+.kv-embed-status { margin-top: 2.5rem; text-align: center; font-size: 0.86rem; color: rgba(226, 232, 240, 0.96); }
-.kv-connect-step {
- display: flex;
- flex-direction: column;
- gap: 0.75rem;
- align-items: center;
-}
+.kv-verify-step { display: flex; flex-direction: column; gap: 0.75rem; align-items: center; text-align: center; }
-.kv-embed-status {
- margin-top: 2.5rem;
- text-align: center;
- font-size: 0.86rem;
- color: rgba(226, 232, 240, 0.96);
-}
-
-.kv-verify-step {
- display: flex;
- flex-direction: column;
- gap: 0.75rem;
- align-items: center;
- text-align: center;
-}
+.kv-verify-copy { max-width: 360px; font-size: 0.76rem; color: rgba(148, 163, 184, 0.95); }
-.kv-verify-copy {
- max-width: 360px;
- font-size: 0.76rem;
- color: rgba(148, 163, 184, 0.95);
-}
+.kv-verify-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center; }
-.kv-verify-actions {
- display: flex;
- flex-wrap: wrap;
- gap: 0.5rem;
- justify-content: center;
-}
-
-.kv-error-state {
- margin-top: 2.2rem;
- text-align: center;
- font-size: 0.86rem;
- color: rgba(226, 232, 240, 0.96);
-}
+.kv-error-state { margin-top: 2.2rem; text-align: center; font-size: 0.86rem; color: rgba(226, 232, 240, 0.96); }
.kv-error-reset {
margin-left: 0.25rem;
@@ -932,17 +693,9 @@
font-size: 0.86rem;
}
-/* ──────────────────────────────────────────────────────────────
- Chakra-tinted session header (kv-chakra-*)
- Root → Sacral → Solar → Heart → Throat → Brow → Crown
- ────────────────────────────────────────────────────────────── */
-
-[class*="kv-chakra-"] {
- position: relative;
- overflow: hidden;
-}
+/* Chakra-tinted session header (kv-chakra-*) */
+[class*="kv-chakra-"] { position: relative; overflow: hidden; }
-/* Shared overlay base */
[class*="kv-chakra-"]::before {
content: "";
position: absolute;
@@ -950,16 +703,8 @@
z-index: -1;
pointer-events: none;
background:
- radial-gradient(
- 140% 160% at 0% 0%,
- rgba(255, 255, 255, 0.06),
- transparent 60%
- ),
- radial-gradient(
- 180% 180% at 100% 100%,
- rgba(15, 23, 42, 0.8),
- transparent 65%
- );
+ radial-gradient(140% 160% at 0% 0%, rgba(255, 255, 255, 0.06), transparent 60%),
+ radial-gradient(180% 180% at 100% 100%, rgba(15, 23, 42, 0.8), transparent 65%);
mix-blend-mode: screen;
opacity: 0.7;
}
@@ -975,107 +720,44 @@
background: transparent;
}
-/* Root — deep crimson to ember */
.kv-chakra-root::after {
background:
- radial-gradient(
- 120% 120% at 5% 0%,
- rgba(248, 113, 113, 0.55),
- transparent 60%
- ),
- radial-gradient(
- 120% 160% at 100% 130%,
- rgba(127, 29, 29, 0.7),
- transparent 65%
- );
+ radial-gradient(120% 120% at 5% 0%, rgba(248, 113, 113, 0.55), transparent 60%),
+ radial-gradient(120% 160% at 100% 130%, rgba(127, 29, 29, 0.7), transparent 65%);
}
-/* Sacral — ember/amber */
.kv-chakra-sacral::after {
background:
- radial-gradient(
- 120% 120% at 5% 0%,
- rgba(251, 146, 60, 0.55),
- transparent 60%
- ),
- radial-gradient(
- 120% 160% at 100% 130%,
- rgba(154, 52, 18, 0.7),
- transparent 65%
- );
+ radial-gradient(120% 120% at 5% 0%, rgba(251, 146, 60, 0.55), transparent 60%),
+ radial-gradient(120% 160% at 100% 130%, rgba(154, 52, 18, 0.7), transparent 65%);
}
-/* Solar — gold */
.kv-chakra-solar::after {
background:
- radial-gradient(
- 120% 120% at 5% 0%,
- rgba(252, 211, 77, 0.6),
- transparent 60%
- ),
- radial-gradient(
- 120% 160% at 100% 130%,
- rgba(180, 83, 9, 0.7),
- transparent 65%
- );
+ radial-gradient(120% 120% at 5% 0%, rgba(252, 211, 77, 0.6), transparent 60%),
+ radial-gradient(120% 160% at 100% 130%, rgba(180, 83, 9, 0.7), transparent 65%);
}
-/* Heart — emerald */
.kv-chakra-heart::after {
background:
- radial-gradient(
- 120% 120% at 5% 0%,
- rgba(74, 222, 128, 0.6),
- transparent 60%
- ),
- radial-gradient(
- 120% 160% at 100% 130%,
- rgba(22, 101, 52, 0.85),
- transparent 65%
- );
+ radial-gradient(120% 120% at 5% 0%, rgba(74, 222, 128, 0.6), transparent 60%),
+ radial-gradient(120% 160% at 100% 130%, rgba(22, 101, 52, 0.85), transparent 65%);
}
-/* Throat — aqua / sky */
.kv-chakra-throat::after {
background:
- radial-gradient(
- 120% 120% at 5% 0%,
- rgba(56, 189, 248, 0.6),
- transparent 60%
- ),
- radial-gradient(
- 120% 160% at 100% 130%,
- rgba(22, 78, 99, 0.85),
- transparent 65%
- );
+ radial-gradient(120% 120% at 5% 0%, rgba(56, 189, 248, 0.6), transparent 60%),
+ radial-gradient(120% 160% at 100% 130%, rgba(22, 78, 99, 0.85), transparent 65%);
}
-/* Brow — indigo */
.kv-chakra-brow::after {
background:
- radial-gradient(
- 120% 120% at 5% 0%,
- rgba(129, 140, 248, 0.65),
- transparent 60%
- ),
- radial-gradient(
- 120% 160% at 100% 130%,
- rgba(49, 46, 129, 0.9),
- transparent 65%
- );
+ radial-gradient(120% 120% at 5% 0%, rgba(129, 140, 248, 0.65), transparent 60%),
+ radial-gradient(120% 160% at 100% 130%, rgba(49, 46, 129, 0.9), transparent 65%);
}
-/* Crown — violet / white-gold */
.kv-chakra-crown::after {
background:
- radial-gradient(
- 120% 120% at 5% 0%,
- rgba(196, 181, 253, 0.7),
- transparent 60%
- ),
- radial-gradient(
- 120% 160% at 100% 130%,
- rgba(76, 29, 149, 0.9),
- transparent 65%
- );
+ radial-gradient(120% 120% at 5% 0%, rgba(196, 181, 253, 0.7), transparent 60%),
+ radial-gradient(120% 160% at 100% 130%, rgba(76, 29, 149, 0.9), transparent 65%);
}
diff --git a/src/components/KaiVoh/styles/KaiVohModal.css b/src/components/KaiVoh/styles/KaiVohModal.css
index b6ce427aa..5dc666dd7 100644
--- a/src/components/KaiVoh/styles/KaiVohModal.css
+++ b/src/components/KaiVoh/styles/KaiVohModal.css
@@ -1,18 +1,20 @@
-/* ──────────────────────────────────────────────────────────────
+/* src/components/KaiVoh/styles/KaiVohModal.css
KaiVohModal.css — Atlantean Glass (Phi / Breath 5.236s)
- HARDENED “NATIVE APP” MODAL (no pull-to-refresh, no scroll chaining,
- no iOS input zoom, no weird reloads when focusing inputs)
- ────────────────────────────────────────────────────────────── */
+ v5.3 — SIMPLIFIED IOS-SAFE MODAL
+ - JS owns the hard lock + touch gating.
+ - CSS stops fighting inputs/keyboard (no global touch-action:none traps).
+ - Scroll is allowed ONLY in .kai-voh-body.
+*/
/* Global fallbacks (TSX sets these on when modal opens) */
:root{
--kai-breath: 5.236s;
--kai-phi: 1.61803398875;
- /* TSX sets --kai-vh = innerHeight px while open (stable iOS viewport) */
+ /* TSX sets --kai-vh = visualViewport height px while open (keyboard-safe) */
--kai-vh: 100vh;
- /* Palette (deep ocean glass + auric cyan/violet + gold) */
+ /* Palette */
--kai-bg: #070a14;
--kai-bg-2: #0b0f1a;
--kai-fg: #e5f6ff;
@@ -42,19 +44,13 @@
/* Layout helpers */
--close-size: 26px;
- /* Orb spacing (used to push the tab bar down) */
+ /* Orb spacing */
--voh-orb-size: 26px;
--voh-orb-top: 6px;
--voh-orb-gap: 8px;
}
-/* ──────────────────────────────────────────────────────────────
- Backdrop + celestial layers
- Key hardening:
- - touch-action: none on backdrop (prevents stray gestures / iOS bounce)
- - overscroll-behavior: none/contain (no scroll chaining)
- - stable height using --kai-vh (set by TSX)
- ────────────────────────────────────────────────────────────── */
+/* Backdrop */
.kai-voh-modal-backdrop{
position: fixed;
inset: 0;
@@ -66,7 +62,6 @@
padding: 0;
- /* Stable viewport while keyboard opens/closes (TSX updates --kai-vh on resize) */
min-height: var(--kai-vh, 100vh);
height: var(--kai-vh, 100vh);
@@ -80,13 +75,14 @@
backdrop-filter: blur(6px) saturate(1.08);
animation: portal-fade .42s ease-out both;
- /* HARD: never allow overscroll/pull-to-refresh from backdrop */
overscroll-behavior: none;
- /* HARD: block pan/zoom gestures at the backdrop level (scroll is allowed only in .kai-voh-body) */
- touch-action: none;
+ /* IMPORTANT:
+ Do NOT set touch-action:none here.
+ iOS ignores touch-action anyway, and on some builds it contributes
+ to weird focus/caret behavior. JS already gates touchmove/wheel. */
+ touch-action: auto;
- /* Prevent highlight/selection weirdness on iOS */
-webkit-tap-highlight-color: transparent;
}
@@ -152,19 +148,13 @@
.atlantean-halo--1{ background: var(--halo-1); animation: halo-orbit var(--kai-breath) ease-in-out infinite alternate; }
.atlantean-halo--2{ background: var(--halo-2); animation: halo-orbit calc(var(--kai-breath) * 1.618) ease-in-out infinite alternate-reverse; }
-/* ──────────────────────────────────────────────────────────────
- Container — Atlantean glass
- Key hardening:
- - max-height uses --kai-vh to stay stable with iOS keyboard
- - touch-action none on shell; scroll only inside .kai-voh-body
- ────────────────────────────────────────────────────────────── */
+/* Container */
.kai-voh-container{
position: relative;
width: 100%;
height: 100%;
- /* Stable iOS keyboard behavior */
max-height: var(--kai-vh, 100vh);
color: var(--kai-fg);
@@ -191,17 +181,17 @@
isolation:isolate;
- /* HARD: block pan/zoom gestures on container (scroll allowed only in inner region) */
- touch-action: none;
+ /* Inputs live here — do not block gestures at container level */
+ touch-action: auto;
- /* Prevent iOS “smart zoom” text scaling */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
- /* Helps reduce paint churn */
- contain: layout paint;
+ /* Keep containment light; avoid breaking fixed descendants in Realms */
+ contain: paint;
}
+/* Glass overlays */
.glass-omni::before{
content:"";
position:absolute; inset:0; pointer-events:none;
@@ -265,11 +255,6 @@
/* ──────────────────────────────────────────────────────────────
Body area (ONLY scrollable region)
- Key hardening:
- - touch-action pan-y => native vertical scroll
- - overscroll-behavior contain => no chaining
- - scrollbar-gutter stable => no layout shifts
- - webkit momentum scrolling enabled
────────────────────────────────────────────────────────────── */
.kai-voh-body{
flex: 1;
@@ -286,18 +271,15 @@
radial-gradient(1.5px 1.5px at 82% 62%, rgba(255,255,255,0.16), transparent 55%),
radial-gradient(1.5px 1.5px at 35% 72%, rgba(255,255,255,0.14), transparent 55%);
- /* No scroll chaining to the page */
overscroll-behavior: contain;
overscroll-behavior-y: contain;
overscroll-behavior-x: none;
- /* Momentum scroll on iOS */
-webkit-overflow-scrolling: touch;
- /* Allow vertical scrolling inside, but block pinch/zoom/pan outside */
+ /* iOS ignores touch-action, but keep it for modern browsers */
touch-action: pan-y;
- /* Avoid desktop scrollbar “jump” when content mounts */
scrollbar-gutter: stable both-edges;
}
@@ -312,10 +294,7 @@
.breath-meter { pointer-events: none; }
/* ──────────────────────────────────────────────────────────────
- FORM HARDENING (prevents iOS “tap to zoom” + keeps native feel)
- - iOS zoom-on-focus happens when font-size < 16px.
- - This forces 16px minimum on all form controls within the modal.
- - Also tightens touch-action so tapping inputs never propagates weird gestures.
+ FORM HARDENING (prevents iOS “tap to zoom”)
────────────────────────────────────────────────────────────── */
.kai-voh-container input,
.kai-voh-container textarea,
@@ -325,7 +304,6 @@
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
- /* Native app feel */
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
@@ -338,7 +316,7 @@
outline: none;
}
-/* Allow text selection inside inputs only (avoid accidental selection on glass) */
+/* Allow text selection inside inputs only */
.kai-voh-container{ user-select: none; -webkit-user-select: none; }
.kai-voh-container input,
.kai-voh-container textarea{ user-select: text; -webkit-user-select: text; }
@@ -362,7 +340,6 @@
width: var(--close-size); height: var(--close-size);
border-radius: 9999px;
- /* hide any text × without harming aria-label */
color: transparent;
text-shadow: none;
font-size: 0;
@@ -373,7 +350,6 @@
outline: none;
-webkit-tap-highlight-color: transparent;
- /* Prismatic glass core (alive) */
background:
radial-gradient(18px 18px at 30% 28%, rgba(255,255,255,0.22), rgba(255,255,255,0.00) 60%),
radial-gradient(26px 26px at 70% 76%, rgba(0,255,208,0.14), rgba(0,0,0,0.00) 62%),
@@ -417,7 +393,6 @@
voh-x-sheen calc(var(--kai-breath) * 2) linear infinite;
}
-/* The “X” itself — two living beams */
.kai-voh-close::before,
.kai-voh-close::after{
content:"";
@@ -445,10 +420,7 @@
animation: voh-x-lines var(--kai-breath) ease-in-out infinite;
}
-
-.kai-voh-close::after{
- transform: translate(-50%,-50%) rotate(-45deg);
-}
+.kai-voh-close::after{ transform: translate(-50%,-50%) rotate(-45deg); }
.kai-voh-close:hover{
border-color: rgba(0,255,208,0.32);
@@ -478,7 +450,6 @@
inset 0 1px 0 rgba(255,255,255,0.12);
}
-/* Animated outline helper if needed elsewhere */
.kai-pulse-border{
outline: 1px solid var(--border-strong);
box-shadow: 0 0 0 1px rgba(0,255,208,.16) inset;
@@ -497,9 +468,7 @@
animation: spin 1s linear infinite;
}
-/* ──────────────────────────────────────────────────────────────
- TOP-CENTER ORB (hard-centered)
- ────────────────────────────────────────────────────────────── */
+/* Top-center orb */
.voh-top-orb{
position: absolute;
top: var(--voh-orb-top);
@@ -512,7 +481,7 @@
filter: drop-shadow(0 0 10px rgba(0,255,208,.35));
}
-/* Minimal emblem styling (mirrors Realms look) */
+/* Minimal emblem styling */
.seal-emblem{ position: relative; width: 100%; height: 100%; }
.seal-ring{
position:absolute; inset:0; border-radius:50%;
@@ -535,17 +504,15 @@
animation: core-pulse var(--kai-breath) ease-in-out infinite;
}
-/* ──────────────────────────────────────────────────────────────
- Tabs + Breath meter
- ────────────────────────────────────────────────────────────── */
+/* Tabs + Breath meter */
.kai-voh-tabbar{
display:flex; gap:8px;
padding: calc(var(--voh-orb-size) + var(--voh-orb-top) + 6px) 10px 0;
align-items:center; justify-content:center;
position: relative;
- /* Don’t allow accidental scroll/pan on the tab bar itself */
- touch-action: none;
+ /* IMPORTANT: do NOT set touch-action:none here. */
+ touch-action: auto;
}
.kai-voh-tab{
@@ -565,12 +532,8 @@
border-color: rgba(0,255,208,.55);
box-shadow: 0 6px 18px rgba(0,255,208,.18);
}
+.auric-tab .tab-glyph{ margin-right: 6px; filter: drop-shadow(0 0 4px rgba(0,255,208,.4)); }
-.auric-tab .tab-glyph{
- margin-right: 6px; filter: drop-shadow(0 0 4px rgba(0,255,208,.4));
-}
-
-/* Breath meter (phi-timed) */
.breath-meter{
position:absolute;
right: 12px;
@@ -590,181 +553,40 @@
animation: meter-move var(--kai-breath) ease-in-out infinite;
}
-/* ──────────────────────────────────────────────────────────────
- Content panes
- ────────────────────────────────────────────────────────────── */
-.portal-pane{
- animation: pane-in .35s ease-out;
- contain: content;
-}
+/* Content panes */
+.portal-pane{ animation: pane-in .35s ease-out; contain: content; }
+.portal-pane--realms{ contain: none; overflow: visible; }
-/* Realms needs to break out of paint containment for its fixed backdrop. */
-.portal-pane--realms{
- contain: none;
- overflow: visible;
-}
-
-/* ──────────────────────────────────────────────────────────────
- Footer / seals / chakra dots
- ────────────────────────────────────────────────────────────── */
-.atlantean-footer{
- position: relative;
- display:grid;
- grid-template-columns: 1fr auto 1fr;
- align-items:center;
- padding: 10px 14px;
- background:
- linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,0.02)),
- radial-gradient(70% 70% at 50% 100%, rgba(0,180,255,.12), transparent 60%);
- border-top: 1px solid rgba(255,255,255,.08);
- color: var(--kai-muted);
- font-size: .9rem;
-
- touch-action: none;
-}
-.footer-left, .footer-right{ display:flex; align-items:center; gap:8px; }
-.footer-left{ justify-self: start; }
-.footer-center{ display:flex; align-items:center; justify-content:center; gap:8px; justify-self: center; }
-.footer-right{ justify-self: end; }
-.breath-label{ opacity:.85; }
-.breath-time{ color: var(--kai-gold); font-weight:700; }
-
-/* Chakra dots */
-.sigil-dot{
- width: 10px; height: 10px; border-radius: 50%;
- filter: drop-shadow(0 0 6px currentColor);
- animation: dot-breathe var(--kai-breath) ease-in-out infinite;
-}
-.sigil-dot--root{ color:#FF0033; }
-.sigil-dot--sacral{ color:#FF8000; }
-.sigil-dot--solar{ color:#FFD700; }
-.sigil-dot--heart{ color:#00FF99; }
-.sigil-dot--throat{ color:#33CCFF; }
-.sigil-dot--third{ color:#9933FF; }
-.sigil-dot--crown{ color:#AA00FF; }
-
-.seal-pulse{
- width: 10px; height: 10px; border-radius: 50%;
- background: radial-gradient(circle at 40% 40%, #fff, var(--kai-violet));
- box-shadow: 0 0 12px rgba(138,43,226,.55), 0 0 20px rgba(0,255,208,.35);
- animation: seal-pulse var(--kai-breath) ease-in-out infinite;
-}
-.seal-text{
- background: linear-gradient(90deg, var(--kai-accent), var(--kai-violet));
- -webkit-background-clip: text; background-clip: text; color: transparent;
- font-weight: 800; letter-spacing:.03em;
-}
-
-/* ──────────────────────────────────────────────────────────────
- Screen-reader helper
- ────────────────────────────────────────────────────────────── */
+/* Screen-reader helper */
.sr-only{
position:absolute !important; width:1px; height:1px;
padding:0; margin:-1px; overflow:hidden; clip: rect(0,0,0,0);
white-space:nowrap; border:0;
}
-/* Chakra row subtle orbit */
-.chakra-row {
- display: flex; gap: 8px; align-items: center;
- animation: chakra-sway calc(var(--kai-breath) * 2) ease-in-out infinite alternate;
-}
-@keyframes chakra-sway {
- 0% { transform: translateY(-0.5px) }
- 100% { transform: translateY(0.5px) }
-}
-
-/* Breathing emblem (center) */
-.breath-emblem{
- position: relative;
- width: 36px; height: 36px;
- display: grid; place-items: center;
- filter: drop-shadow(0 0 10px rgba(0,255,208,.35));
-}
-.breath-emblem__ring{
- position:absolute; inset:0; border-radius:50%;
- border: 1.5px solid rgba(0,255,208,.35);
- box-shadow: 0 0 10px rgba(0,255,208,.25) inset;
-}
-.breath-emblem__ring--1{
- animation: ring-breathe var(--kai-breath) ease-in-out infinite;
-}
-.breath-emblem__ring--2{
- inset: 6px;
- border-color: rgba(138,43,226,.38);
- box-shadow: 0 0 12px rgba(138,43,226,.28) inset;
- animation: ring-breathe calc(var(--kai-breath) * 1.618) ease-in-out infinite reverse;
-}
-.breath-emblem__core{
- width: 10px; height: 10px; border-radius: 50%;
- background: radial-gradient(circle at 40% 40%, #fff, #00ffd0);
- box-shadow: 0 0 16px rgba(0,255,208,.6), 0 0 26px rgba(0,180,255,.35);
- animation: core-pulse var(--kai-breath) ease-in-out infinite;
-}
-@keyframes core-pulse{
- 0%{ transform: scale(.92) }
- 50%{ transform: scale(1.18) }
- 100%{ transform: scale(.92) }
-}
-
-/* ──────────────────────────────────────────────────────────────
- Animations
- ────────────────────────────────────────────────────────────── */
+/* Animations */
@keyframes portal-fade{ from{ opacity:0 } to{ opacity:1 } }
-
-@keyframes veil-drift{
- 0%{ transform: translate3d(0,0,0) }
- 100%{ transform: translate3d(0,-2.5%,0) }
-}
-
+@keyframes veil-drift{ 0%{ transform: translate3d(0,0,0) } 100%{ transform: translate3d(0,-2.5%,0) } }
@keyframes stars-twinkle{
0%,100%{ opacity:.45; filter: drop-shadow(0 0 1px rgba(255,255,255,.15)); }
50%{ opacity:.8; filter: drop-shadow(0 0 3px rgba(255,255,255,.35)); }
}
-
-@keyframes halo-orbit{
- 0%{ transform: translateY(-1.2%) scale(1) }
- 100%{ transform: translateY(1.2%) scale(1.012) }
-}
-
+@keyframes halo-orbit{ 0%{ transform: translateY(-1.2%) scale(1) } 100%{ transform: translateY(1.2%) scale(1.012) } }
@keyframes ring-breathe{
0%{ opacity:.65; transform: scale(1) }
50%{ opacity:1; transform: scale( calc(1 + (0.012 * var(--kai-phi)) ) ) }
100%{ opacity:.65; transform: scale(1) }
}
-
-@keyframes spiral-dash{
- 0%{ stroke-dashoffset: 900; opacity:.25 }
- 50%{ opacity:.6 }
- 100%{ stroke-dashoffset: 0; opacity:.25 }
-}
-
+@keyframes spiral-dash{ 0%{ stroke-dashoffset: 900; opacity:.25 } 50%{ opacity:.6 } 100%{ stroke-dashoffset: 0; opacity:.25 } }
@keyframes spin{ to{ transform: rotate(360deg) } }
-
@keyframes meter-move{
0%{ transform: translateX(0) scale(.9) }
50%{ transform: translateX(102px) scale(1.05) }
100%{ transform: translateX(0) scale(.9) }
}
+@keyframes core-pulse{ 0%{ transform: scale(.92) } 50%{ transform: scale(1.18) } 100%{ transform: scale(.92) } }
+@keyframes pane-in{ from{ opacity:0; transform: translateY(6px) } to{ opacity:1; transform: translateY(0) } }
-@keyframes dot-breathe{
- 0%{ transform: scale(.9); filter: drop-shadow(0 0 3px currentColor) }
- 50%{ transform: scale(1.2); filter: drop-shadow(0 0 8px currentColor) }
- 100%{ transform: scale(.9); filter: drop-shadow(0 0 3px currentColor) }
-}
-
-@keyframes seal-pulse{
- 0%{ transform: scale(.92); box-shadow: 0 0 8px rgba(138,43,226,.35), 0 0 14px rgba(0,255,208,.25) }
- 50%{ transform: scale(1.12); box-shadow: 0 0 18px rgba(138,43,226,.6), 0 0 28px rgba(0,255,208,.35) }
- 100%{ transform: scale(.92); box-shadow: 0 0 8px rgba(138,43,226,.35), 0 0 14px rgba(0,255,208,.25) }
-}
-
-@keyframes pane-in{
- from{ opacity:0; transform: translateY(6px) }
- to{ opacity:1; transform: translateY(0) }
-}
-
-/* Close “X” life */
@keyframes voh-x-breath{
0%{
border-color: rgba(180,200,255,0.14);
@@ -804,7 +626,6 @@
50% { background-position: 0 0, 0 0, 0 0, 0 0, 0 0, 120% 35%; }
100% { background-position: 0 0, 0 0, 0 0, 0 0, 0 0, 240% 55%; }
}
-
@keyframes voh-x-lines{
0%{
opacity: .84;
@@ -826,9 +647,7 @@
}
}
-/* ──────────────────────────────────────────────────────────────
- Motion safety
- ────────────────────────────────────────────────────────────── */
+/* Motion safety */
@media (prefers-reduced-motion: reduce){
*{ animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition: none !important; }
.kai-voh-close,
@@ -836,10 +655,7 @@
.kai-voh-close::after{ animation: none !important; }
}
-/* ──────────────────────────────────────────────────────────────
- Small screens (keep ornaments; scale/reposition instead)
- + keep iOS input zoom disabled (still 16px)
- ────────────────────────────────────────────────────────────── */
+/* Small screens */
@media (max-width: 560px){
:root{
--voh-orb-size: 36px;
@@ -848,18 +664,15 @@
.kai-voh-close{ top:10px; right:10px; }
- /* keep the pulse meter; shift left so it doesn't hit the X */
.breath-meter{
right: calc(10px + var(--close-size) + 8px);
top: 10px;
width: 96px;
}
- /* keep spirals visible; just scale them a touch */
.phi-spiral--tl{ top:-8px; left:-12px; transform: rotate(2deg) scale(.78); }
.phi-spiral--br{ bottom:-6px; right:-8px; transform: rotate(182deg) scale(.78); }
- /* extra safety: never drop below 16px */
.kai-voh-container input,
.kai-voh-container textarea,
.kai-voh-container select{ font-size: 16px !important; }
diff --git a/src/components/SigilExplorer.tsx b/src/components/SigilExplorer.tsx
index 1c874e9a2..091c606bb 100644
--- a/src/components/SigilExplorer.tsx
+++ b/src/components/SigilExplorer.tsx
@@ -1543,10 +1543,17 @@ async function flushInhaleQueue(): Promise {
fd.append("file", blob, `sigils_${randId()}.json`);
const makeUrl = (base: string) => {
- const url = new URL(API_INHALE_PATH, base);
+ if (base) {
+ const url = new URL(API_INHALE_PATH, base);
+ url.searchParams.set("include_state", "false");
+ url.searchParams.set("include_urls", "false");
+ return url.toString();
+ }
+
+ const url = new URL(API_INHALE_PATH, "http://placeholder");
url.searchParams.set("include_state", "false");
url.searchParams.set("include_urls", "false");
- return url.toString();
+ return `${API_INHALE_PATH}${url.search}`;
};
const res = await apiFetchWithFailover(makeUrl, { method: "POST", body: fd });
@@ -1776,10 +1783,17 @@ async function pullAndImportRemoteUrls(
const r = await apiFetchJsonWithFailover(
(base) => {
- const url = new URL(API_URLS_PATH, base);
+ if (base) {
+ const url = new URL(API_URLS_PATH, base);
+ url.searchParams.set("offset", String(offset));
+ url.searchParams.set("limit", String(URLS_PAGE_LIMIT));
+ return url.toString();
+ }
+
+ const url = new URL(API_URLS_PATH, "http://placeholder");
url.searchParams.set("offset", String(offset));
url.searchParams.set("limit", String(URLS_PAGE_LIMIT));
- return url.toString();
+ return `${API_URLS_PATH}${url.search}`;
},
{ method: "GET", signal, cache: "no-store" },
);
@@ -3271,12 +3285,15 @@ const SigilExplorer: React.FC = () => {
// (B) EXHALE — seal check
const prevSeal = remoteSealRef.current;
- const res = await apiFetchWithFailover((base) => new URL(API_SEAL_PATH, base).toString(), {
- method: "GET",
- cache: "no-store",
- signal: ac.signal,
- headers: undefined,
- });
+ const res = await apiFetchWithFailover(
+ (base) => (base ? new URL(API_SEAL_PATH, base).toString() : API_SEAL_PATH),
+ {
+ method: "GET",
+ cache: "no-store",
+ signal: ac.signal,
+ headers: undefined,
+ },
+ );
if (!res) return;
@@ -3736,4 +3753,4 @@ breathTimer = null;
);
};
-export default SigilExplorer;
\ No newline at end of file
+export default SigilExplorer;
diff --git a/src/components/SigilExplorer/SigilExplorer.tsx b/src/components/SigilExplorer/SigilExplorer.tsx
index 430f69b98..eee703c82 100644
--- a/src/components/SigilExplorer/SigilExplorer.tsx
+++ b/src/components/SigilExplorer/SigilExplorer.tsx
@@ -1844,12 +1844,15 @@ const SigilExplorer: React.FC = () => {
try {
const prevSeal = remoteSealRef.current;
- const res = await apiFetchWithFailover((base) => new URL(API_SEAL_PATH, base).toString(), {
- method: "GET",
- cache: "no-store",
- signal: ac.signal,
- headers: undefined,
- });
+ const res = await apiFetchWithFailover(
+ (base) => (base ? new URL(API_SEAL_PATH, base).toString() : API_SEAL_PATH),
+ {
+ method: "GET",
+ cache: "no-store",
+ signal: ac.signal,
+ headers: undefined,
+ },
+ );
if (!res) return;
if (res.status === 304) return;
diff --git a/src/components/SigilExplorer/SigilHoneycombExplorer.tsx b/src/components/SigilExplorer/SigilHoneycombExplorer.tsx
index 5d16eada7..d110ee9cc 100644
--- a/src/components/SigilExplorer/SigilHoneycombExplorer.tsx
+++ b/src/components/SigilExplorer/SigilHoneycombExplorer.tsx
@@ -926,7 +926,7 @@ export default function SigilHoneycombExplorer({
syncInFlightRef.current = true;
try {
const res = await apiFetchWithFailover(
- (base) => new URL(API_SEAL_PATH, base).toString(),
+ (base) => (base ? new URL(API_SEAL_PATH, base).toString() : API_SEAL_PATH),
{ method: "GET", cache: "no-store", signal, headers: undefined },
);
diff --git a/src/components/SigilExplorer/apiClient.ts b/src/components/SigilExplorer/apiClient.ts
index 6196962df..bd80ad150 100644
--- a/src/components/SigilExplorer/apiClient.ts
+++ b/src/components/SigilExplorer/apiClient.ts
@@ -30,6 +30,8 @@ const canStorage = hasWindow && typeof window.localStorage !== "undefined";
* ─────────────────────────────────────────────────────────────────── */
export const LIVE_BASE_URL = "https://m.kai.ac";
export const LIVE_BACKUP_URL = "https://memory.kaiklok.com";
+const PROXY_API_BASE = ""; // same-origin proxy ("/sigils" handled by the app server)
+const ENABLE_PROXY_IN_PROD = import.meta.env.VITE_SIGIL_PROXY === "true";
/**
* Dev API base:
@@ -45,6 +47,15 @@ function isLocalDevOrigin(origin: string): boolean {
return origin.startsWith("http://localhost:") || origin.startsWith("http://127.0.0.1:");
}
+function shouldTrySameOriginProxy(origin: string): boolean {
+ if (!origin || origin === "null") return false;
+ if (isLocalDevOrigin(origin)) return true;
+ if (import.meta.env.DEV) return true;
+ if (!ENABLE_PROXY_IN_PROD) return false;
+ if (origin === LIVE_BASE_URL || origin === LIVE_BACKUP_URL) return false;
+ return true;
+}
+
function selectPrimaryBase(primary: string, backup: string): string {
if (!hasWindow) return primary;
@@ -173,8 +184,11 @@ function apiBases(): string[] {
return protocolFiltered.filter((b) => b === pageOrigin);
}
- // Soft-fail: suppress backup if marked dead
- return isBackupSuppressed() ? protocolFiltered.filter((b) => b !== API_BASE_FALLBACK) : protocolFiltered;
+ const filtered = isBackupSuppressed()
+ ? protocolFiltered.filter((b) => b !== API_BASE_FALLBACK)
+ : protocolFiltered;
+ const shouldProxy = hasWindow && shouldTrySameOriginProxy(pageOrigin);
+ return shouldProxy ? [PROXY_API_BASE, ...filtered] : filtered;
}
function shouldFailoverStatus(status: number): boolean {
diff --git a/src/components/SigilExplorer/inhaleQueue.ts b/src/components/SigilExplorer/inhaleQueue.ts
index 9ce2a47a0..afc02fa3c 100644
--- a/src/components/SigilExplorer/inhaleQueue.ts
+++ b/src/components/SigilExplorer/inhaleQueue.ts
@@ -9,6 +9,7 @@ import { memoryRegistry, isOnline } from "./registryStore";
const hasWindow = typeof window !== "undefined";
const INHALE_BATCH_MAX = 200;
+const INHALE_BATCH_MAX_BYTES = 220_000;
const INHALE_DEBOUNCE_MS = 180;
const INHALE_RETRY_BASE_MS = 1200;
const INHALE_RETRY_MAX_MS = 12000;
@@ -219,11 +220,53 @@ async function flushInhaleQueue(): Promise {
try {
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 jsonPreview = JSON.stringify(next);
+ const size =
+ encoder != null
+ ? encoder.encode(jsonPreview).byteLength
+ : new Blob([jsonPreview]).size;
+
+ if (batch.length === 0 && size > INHALE_BATCH_MAX_BYTES) {
+ inhaleQueue.delete(k);
+ droppedOversize = true;
+ continue;
+ }
+
+ if (
+ batch.length > 0 &&
+ (batch.length >= INHALE_BATCH_MAX || size > INHALE_BATCH_MAX_BYTES)
+ ) {
+ break;
+ }
+
batch.push(v);
keys.push(k);
- if (batch.length >= INHALE_BATCH_MAX) break;
+ currentBytes = size;
+
+ if (batch.length >= INHALE_BATCH_MAX || currentBytes >= INHALE_BATCH_MAX_BYTES) {
+ break;
+ }
+ }
+
+ if (droppedOversize) {
+ saveInhaleQueueToStorage();
+ }
+
+ if (batch.length === 0) {
+ inhaleRetryMs = 0;
+ if (inhaleQueue.size > 0) {
+ inhaleFlushTimer = window.setTimeout(() => {
+ inhaleFlushTimer = null;
+ void flushInhaleQueue();
+ }, 10);
+ }
+ return;
}
const json = JSON.stringify(batch);
@@ -232,10 +275,17 @@ async function flushInhaleQueue(): Promise {
fd.append("file", blob, `sigils_${randId()}.json`);
const makeUrl = (base: string) => {
- const url = new URL(API_INHALE_PATH, base);
+ if (base) {
+ const url = new URL(API_INHALE_PATH, base);
+ url.searchParams.set("include_state", "false");
+ url.searchParams.set("include_urls", "false");
+ return url.toString();
+ }
+
+ const url = new URL(API_INHALE_PATH, "http://placeholder");
url.searchParams.set("include_state", "false");
url.searchParams.set("include_urls", "false");
- return url.toString();
+ return `${API_INHALE_PATH}${url.search}`;
};
const res = await apiFetchWithFailover(makeUrl, { method: "POST", body: fd });
diff --git a/src/components/SigilExplorer/registryStore.ts b/src/components/SigilExplorer/registryStore.ts
index 854ff4258..f5d32c14f 100644
--- a/src/components/SigilExplorer/registryStore.ts
+++ b/src/components/SigilExplorer/registryStore.ts
@@ -6,7 +6,7 @@ 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 { resolveLineageBackwards } from "../../utils/sigilUrl";
+import { makeSigilUrlLoose, resolveLineageBackwards, type SigilSharePayloadLoose as SigilUrlPayloadLoose } from "../../utils/sigilUrl";
import { getInMemorySigilUrls } from "../../utils/sigilRegistry";
import { markConfirmedByNonce } from "../../utils/sendLedger";
import {
@@ -22,6 +22,7 @@ import { enqueueInhaleKrystal } from "./inhaleQueue";
export const REGISTRY_LS_KEY = "kai:sigils:v1"; // explorer’s persisted URL list
export const MODAL_FALLBACK_LS_KEY = "sigil:urls"; // composer/modal fallback URL list
+export const NOTE_CLAIM_LS_KEY = "kai:sigil-claims:v1"; // persistent note-claim registry
const BC_NAME = "kai-sigil-registry";
const WITNESS_ADD_MAX = 512;
@@ -29,8 +30,10 @@ const WITNESS_ADD_MAX = 512;
const hasWindow = typeof window !== "undefined";
const canStorage = hasWindow && typeof window.localStorage !== "undefined";
let registryHydrated = false;
+let noteClaimsHydrated = false;
export const memoryRegistry: Registry = new Map();
+const noteClaimRegistry: Map> = new Map();
const channel = hasWindow && "BroadcastChannel" in window ? new BroadcastChannel(BC_NAME) : null;
export type AddSource = "local" | "remote" | "hydrate" | "import";
@@ -43,6 +46,12 @@ export type AddUrlOptions = {
enqueueToApi?: boolean;
};
+type NoteClaimArgs = {
+ parentCanonical: string;
+ transferNonce: string;
+ childCanonical?: string;
+};
+
export function isOnline(): boolean {
if (!hasWindow) return false;
if (typeof navigator === "undefined") return true;
@@ -80,6 +89,125 @@ function readTransferDirectionFromPayload(payload: SigilSharePayloadLoose): "sen
);
}
+function normalizeCanonical(raw: string | undefined | null): string {
+ return raw ? raw.trim().toLowerCase() : "";
+}
+
+function normalizeNonce(raw: string | undefined | null): string {
+ return raw ? raw.trim() : "";
+}
+
+function buildNoteClaimPayload(args: NoteClaimArgs): SigilUrlPayloadLoose {
+ const { parentCanonical, transferNonce, childCanonical } = args;
+ const payload: SigilUrlPayloadLoose = {
+ pulse: 0,
+ beat: 0,
+ stepIndex: 0,
+ chakraDay: "Root",
+ transferDirection: "receive",
+ transferNonce,
+ parentCanonical,
+ };
+ if (childCanonical) {
+ payload.canonicalHash = childCanonical;
+ payload.childHash = childCanonical;
+ payload.hash = childCanonical;
+ }
+ return payload;
+}
+
+function buildNoteClaimUrl(args: NoteClaimArgs): string {
+ const payload = buildNoteClaimPayload(args);
+ return makeSigilUrlLoose(args.parentCanonical, payload, { absolute: true });
+}
+
+function hydrateNoteClaimsFromStorage(): boolean {
+ if (!canStorage) return false;
+ try {
+ const raw = localStorage.getItem(NOTE_CLAIM_LS_KEY);
+ if (!raw) return false;
+ const parsed = JSON.parse(raw) as unknown;
+ if (!parsed || typeof parsed !== "object") return false;
+ let changed = false;
+ for (const [parent, value] of Object.entries(parsed as Record)) {
+ if (!Array.isArray(value)) continue;
+ const parentKey = normalizeCanonical(parent);
+ if (!parentKey) continue;
+ const set = noteClaimRegistry.get(parentKey) ?? new Set();
+ for (const entry of value) {
+ if (typeof entry !== "string") continue;
+ const nonce = normalizeNonce(entry);
+ if (!nonce) continue;
+ if (!set.has(nonce)) {
+ set.add(nonce);
+ changed = true;
+ }
+ }
+ if (set.size > 0) noteClaimRegistry.set(parentKey, set);
+ }
+ return changed;
+ } catch {
+ return false;
+ }
+}
+
+function persistNoteClaimsToStorage(): void {
+ if (!canStorage) return;
+ try {
+ const obj: Record = {};
+ for (const [parent, nonces] of noteClaimRegistry.entries()) {
+ const list = Array.from(nonces.values());
+ if (list.length > 0) obj[parent] = list;
+ }
+ localStorage.setItem(NOTE_CLAIM_LS_KEY, JSON.stringify(obj));
+ } catch {
+ // ignore quota issues
+ }
+}
+
+function ensureNoteClaimsHydrated(): void {
+ if (noteClaimsHydrated) return;
+ noteClaimsHydrated = true;
+ hydrateNoteClaimsFromStorage();
+}
+
+export function markNoteClaimed(
+ parentCanonical: string,
+ transferNonce: string,
+ args?: { childCanonical?: string },
+): boolean {
+ ensureNoteClaimsHydrated();
+ 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 claimPayload = buildNoteClaimPayload({
+ parentCanonical: parentKey,
+ transferNonce: nonce,
+ childCanonical: args?.childCanonical,
+ });
+ const claimUrl = buildNoteClaimUrl({
+ parentCanonical: parentKey,
+ transferNonce: nonce,
+ childCanonical: args?.childCanonical,
+ });
+ upsertRegistryPayload(claimUrl, claimPayload);
+ enqueueInhaleKrystal(claimUrl, claimPayload);
+ return true;
+}
+
+export function isNoteClaimed(parentCanonical: string, transferNonce: string): boolean {
+ ensureNoteClaimsHydrated();
+ const parentKey = normalizeCanonical(parentCanonical);
+ const nonce = normalizeNonce(transferNonce);
+ if (!parentKey || !nonce) return false;
+ return noteClaimRegistry.get(parentKey)?.has(nonce) ?? false;
+}
+
function safeDecodeURIComponent(v: string): string {
try {
return decodeURIComponent(v);
@@ -362,6 +490,7 @@ export function hydrateRegistryFromStorage(): boolean {
export function ensureRegistryHydrated(): boolean {
if (registryHydrated) return false;
registryHydrated = true;
+ ensureNoteClaimsHydrated();
return hydrateRegistryFromStorage();
}
@@ -389,9 +518,14 @@ export function addUrl(url: string, opts?: AddUrlOptions): boolean {
if (readTransferDirectionFromPayload(extracted) === "receive") {
const record = extracted as unknown as Record;
- const parentHash = readStringField(record, "parentHash");
+ const parentHash = readStringField(record, "parentHash") ?? readStringField(record, "parentCanonical");
const nonce = readStringField(record, "transferNonce") ?? readStringField(record, "nonce");
- if (parentHash && nonce) markConfirmedByNonce(parentHash, nonce);
+ const childCanonical =
+ readStringField(record, "canonicalHash") ?? readStringField(record, "childHash") ?? readStringField(record, "hash");
+ if (parentHash && nonce) {
+ markConfirmedByNonce(parentHash, nonce);
+ markNoteClaimed(parentHash, nonce, { childCanonical: childCanonical ?? undefined });
+ }
}
if (includeAncestry && ctx.chain.length > 0) {
diff --git a/src/components/SovereignDeclarations.tsx b/src/components/SovereignDeclarations.tsx
index 765c37fdc..c98663163 100644
--- a/src/components/SovereignDeclarations.tsx
+++ b/src/components/SovereignDeclarations.tsx
@@ -36,7 +36,7 @@ export default function SovereignDeclarations(): React.JSX.Element {
const openVault = useCallback(() => setOpen(true), []);
const triggerSummary = useMemo(
- () => "Kairos Notes — Legal Tender",
+ () => "☤Kairos Notes — Legal Tender",
[]
);
@@ -223,7 +223,7 @@ Sovereign Writ
- Kairos Notes are legal tender — sealed by Proof of Breath™, pulsed by Kai-Signature™,
+ ☤Kairos Notes are legal tender — sealed by Proof of Breath™, pulsed by Kai-Signature™,
auditable as:
Σ → SHA-256(Σ) → Φ.
diff --git a/src/components/VerifierStamper/VerifierStamper.tsx b/src/components/VerifierStamper/VerifierStamper.tsx
index 715e17d7c..bac9265be 100644
--- a/src/components/VerifierStamper/VerifierStamper.tsx
+++ b/src/components/VerifierStamper/VerifierStamper.tsx
@@ -75,7 +75,7 @@ import SealMomentModal from "../SealMomentModalTransfer";
import ValuationModal from "../ValuationModal";
import { buildValueSeal, attachValuation, type ValueSeal } from "../../utils/valuation";
import NotePrinter from "../ExhaleNote";
-import type { BanknoteInputs as NoteBanknoteInputs, VerifierBridge } from "../exhale-note/types";
+import type { BanknoteInputs as NoteBanknoteInputs, NoteSendPayload, NoteSendResult, VerifierBridge } from "../exhale-note/types";
import { kaiPulseNow, SIGIL_CTX, SIGIL_TYPE, SEGMENT_SIZE } from "./constants";
import { sha256Hex, phiFromPublicKey } from "./crypto";
@@ -99,7 +99,7 @@ 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, getSpentScaledFor, markConfirmedByLeaf } from "../../utils/sendLedger";
+import { recordSend, getReservedScaledFor, markConfirmedByLeaf } from "../../utils/sendLedger";
import { recordSigilTransferMovement } from "../../utils/sigilTransferRegistry";
import {
buildBundleRoot,
@@ -647,26 +647,73 @@ const VerifierStamperInner: React.FC = () => {
};
const noteInitial = useMemo(
- () =>
- buildNotePayload({
+ () => {
+ const base = buildNotePayload({
meta,
sigilSvgRaw,
verifyUrl: sealUrl || (typeof window !== "undefined" ? window.location.href : ""),
pulseNow,
- }),
- [meta, sigilSvgRaw, sealUrl, 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 : "");
+
+ 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;
- const p = buildNotePayload({
+ 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);
try {
@@ -1932,17 +1979,20 @@ const VerifierStamperInner: React.FC = () => {
return toScaledBig(String(initialGlyph?.value ?? 0) || "0");
}, [isChildContext, meta, lastTransfer, persistedBaseScaled, pivotIndex, initialGlyph]);
- const ledgerSpentScaled = useMemo(() => {
+ const ledgerReservedScaled = useMemo(() => {
if (!canonical) return 0n;
try {
- return getSpentScaledFor(canonical);
+ return getReservedScaledFor(canonical);
} catch (err) {
- logError("ledgerSpentScaled", err);
+ logError("ledgerReservedScaled", err);
return 0n;
}
}, [canonical]);
- const totalSpentScaled = useMemo(() => (isChildContext ? 0n : ledgerSpentScaled), [isChildContext, ledgerSpentScaled]);
+ const totalSpentScaled = useMemo(
+ () => (isChildContext ? 0n : ledgerReservedScaled),
+ [isChildContext, ledgerReservedScaled]
+ );
const remainingPhiScaled = useMemo(
() => (basePhiScaled > totalSpentScaled ? basePhiScaled - totalSpentScaled : 0n),
@@ -1954,6 +2004,94 @@ const VerifierStamperInner: React.FC = () => {
[remainingPhiScaled]
);
+ const noteSendBusyRef = useRef(false);
+ const handleNoteSend = useCallback(
+ async (payload: NoteSendPayload): Promise => {
+ if (noteSendBusyRef.current) return;
+ noteSendBusyRef.current = true;
+ try {
+ const parentCanonical = (canonical ?? "").toLowerCase();
+ if (!parentCanonical) throw new Error("Origin sigil not initialized.");
+
+ const amountScaled = toScaledBig(String(payload.amountPhi || 0));
+ if (amountScaled <= 0n) throw new Error("Invalid Φ amount.");
+ if (amountScaled > remainingPhiScaled) {
+ throw new Error(
+ `Exhale exceeds resonance Φ — requested Φ ${fromScaledBigFixed(amountScaled, 4)} but only Φ ${remainingPhiDisplay4} remains on this glyph.`
+ );
+ }
+
+ const metaOpt = meta as SigilMetadataWithOptionals | null;
+ const previousHeadRoot = metaOpt?.transfersWindowRootV14 ?? metaOpt?.transfersWindowRoot ?? "";
+ const senderStamp = payload.valuationStamp || "";
+ const senderKaiPulse = payload.lockedPulse;
+ const transferNonce = payload.transferNonce || genNonce();
+
+ const leafSeed = stableStringify({
+ parent: parentCanonical,
+ nonce: transferNonce,
+ amount: amountScaled.toString(),
+ pulse: senderKaiPulse,
+ stamp: senderStamp,
+ root: previousHeadRoot,
+ });
+ const transferLeafHashSend = (await sha256Hex(leafSeed)).toLowerCase();
+
+ const childSeed = stableStringify({
+ parent: parentCanonical,
+ nonce: transferNonce,
+ senderStamp,
+ senderKaiPulse,
+ prevHead: previousHeadRoot,
+ leafSend: transferLeafHashSend,
+ });
+ const childCanonical = (await sha256Hex(childSeed)).toLowerCase();
+
+ const rec = {
+ parentCanonical,
+ childCanonical,
+ amountPhiScaled: amountScaled.toString(),
+ senderKaiPulse,
+ transferNonce,
+ senderStamp,
+ previousHeadRoot,
+ transferLeafHashSend,
+ };
+
+ await recordSend(rec);
+ recordSigilTransferMovement({
+ hash: childCanonical,
+ direction: "send",
+ amountPhi: payload.amountPhi,
+ amountUsd: payload.amountUsd != null ? payload.amountUsd.toFixed(2) : undefined,
+ sentPulse: senderKaiPulse,
+ });
+ try {
+ getSigilGlobal().registerSend?.(rec);
+ } catch (err) {
+ logError("__SIGIL__.registerSend", err);
+ }
+ try {
+ window.dispatchEvent(new CustomEvent("sigil:sent", { detail: rec }));
+ } catch (err) {
+ logError("dispatchEvent(sigil:sent)", err);
+ }
+ return {
+ ...payload,
+ parentCanonical,
+ childCanonical,
+ senderKaiPulse,
+ senderStamp,
+ previousHeadRoot,
+ transferLeafHashSend,
+ };
+ } finally {
+ noteSendBusyRef.current = false;
+ }
+ },
+ [canonical, meta, remainingPhiScaled, remainingPhiDisplay4],
+ );
+
// Snap headline Φ to 6dp for UI (math stays BigInt elsewhere)
const headerPhi = useMemo(() => snap6(Number(fromScaledBig(remainingPhiScaled))), [remainingPhiScaled]);
@@ -3722,7 +3860,13 @@ const VerifierStamperInner: React.FC = () => {
{sigilSvgRaw && metaLiteForNote ? (
-
+
) : sigilSvgRaw ? (
Missing valuation metadata for Note — upload/parse a sigil first.
) : (
diff --git a/src/components/exhale-note/banknoteSvg.ts b/src/components/exhale-note/banknoteSvg.ts
index 5b03788eb..ce11a4945 100644
--- a/src/components/exhale-note/banknoteSvg.ts
+++ b/src/components/exhale-note/banknoteSvg.ts
@@ -19,6 +19,7 @@ export interface BuildBanknoteSvgOpts {
// identity / valuation (appears in note + proof pages)
valuePhi?: string;
+ valueUsd?: string;
premiumPhi?: string;
computedPulse?: string; // frozen pulse
nowPulse?: string; // fallback pulse
@@ -30,6 +31,7 @@ export interface BuildBanknoteSvgOpts {
// sigil + verify
sigilSvg?: string; // raw SVG for slot
verifyUrl?: string; // used for QR & clickable slot
+ qrPayload?: string; // optional QR payload (e.g. verifier pointer)
// provenance (optional)
provenance?: ProvenanceRow[];
@@ -47,6 +49,7 @@ export function buildBanknoteSVG(opts: BuildBanknoteSvgOpts): string {
remark = "In Yahuah We Trust — Secured by Φ, not man-made law",
valuePhi = "0",
+ valueUsd = "—",
premiumPhi = "0",
computedPulse = "",
nowPulse = "",
@@ -57,6 +60,7 @@ export function buildBanknoteSVG(opts: BuildBanknoteSvgOpts): string {
sigilSvg = "",
verifyUrl = "/",
+ qrPayload,
provenance = [],
} = opts;
@@ -75,6 +79,7 @@ export function buildBanknoteSVG(opts: BuildBanknoteSvgOpts): string {
const serialCore = (kaiSignature ? kaiSignature.slice(0, 12).toUpperCase() : "Φ".repeat(12))
.replace(/[^0-9A-F]/g, "Φ");
const serial = `K℞K-${serialCore}-${pulseRendered}`;
+ const ValueUsd = valueUsd?.trim() ? valueUsd : "—";
// Guilloché rosette
const rosette = (() => {
@@ -98,14 +103,36 @@ export function buildBanknoteSVG(opts: BuildBanknoteSvgOpts): string {
const sigilEmbed = embedSigilIntoSlotClickable(sigilInner, slot.x, slot.y, slot.w, slot.h, verifyUrl);
// QR block (avoid 8-digit hex colors; use stroke-opacity instead)
- const qrSvg = makeQrSvgTagSafe(verifyUrl, 110, 2);
+ // QR block — perfectly centered inside the 110×110 slot (and thus the framed box)
+ const QR_PX = 110; // intended QR slot size
+ const QR_PAD = 8; // frame padding used by the rect (x/y = -8)
+ const QR_BOX_W = QR_PX + QR_PAD * 2; // 126
+ const QR_BOX_H = 142; // keep your existing tuned height (label fits)
+
+ const qrSvg = makeQrSvgTagSafe(qrPayload || verifyUrl, QR_PX, 2);
+
+ // makeQrSvgTagSafe() may output an SVG smaller than QR_PX due to integer cell sizing.
+ // We measure that and center it inside the QR_PX slot.
+ const qrSvgPx = (() => {
+ const mw = qrSvg.match(/\bwidth="(\d+(?:\.\d+)?)"/i);
+ const mh = qrSvg.match(/\bheight="(\d+(?:\.\d+)?)"/i);
+ const n = mw ? Number(mw[1]) : mh ? Number(mh[1]) : QR_PX;
+ return Number.isFinite(n) && n > 0 ? n : QR_PX;
+ })();
+
+ // integer offset to preserve crispEdges as much as possible
+ const qrOff = Math.max(0, Math.round((QR_PX - qrSvgPx) / 2));
+
const qrBlock = `
-
- ${qrSvg}
- SCAN • VERIFY
+
+ ${qrSvg}
+ SCAN • VERIFY
`;
+
// Optional provenance (last 3 lines)
let provLines = "";
try {
@@ -261,6 +288,7 @@ export function buildBanknoteSVG(opts: BuildBanknoteSvgOpts): string {
VALUE
Φ${esc(valuePhi)}
PREMIUM Φ${esc(premiumPhi)}
+ ≈ ${esc(ValueUsd)} USD
@@ -301,7 +329,7 @@ export function buildBanknoteSVG(opts: BuildBanknoteSvgOpts): string {
SERIAL: ${esc(serial)}
- PULSE (VALUATION): ${esc(pulseRendered)}
+ PULSE (VALUATION): ${esc(pulseRendered)}
KaiSignature: ${esc(kaiSignature || "—")}
ΦKey: ${esc(userPhiKey || "—")}
Algorithm (Valuation): ${esc(valuationAlg || "—")}
diff --git a/src/components/exhale-note/constants.ts b/src/components/exhale-note/constants.ts
index 1dfde22bf..be359925c 100644
--- a/src/components/exhale-note/constants.ts
+++ b/src/components/exhale-note/constants.ts
@@ -5,4 +5,4 @@
export const GENESIS_MS = 1715323541888; // 2024-05-10 06:45:41.888 UTC
export const PULSE_SECONDS = 5.236;
export const PULSE_MS = Math.round(PULSE_SECONDS * 1000);
-export const NOTE_TITLE = "KAIROS NOTE — LEGAL TENDER OF THE SOVEREIGN KINGDOM";
\ No newline at end of file
+export const NOTE_TITLE = "☤KAIROS NOTE — LEGAL TENDER OF THE SOVEREIGN KINGDOM";
\ No newline at end of file
diff --git a/src/components/exhale-note/proofPages.ts b/src/components/exhale-note/proofPages.ts
index c5521a71d..9355efd1b 100644
--- a/src/components/exhale-note/proofPages.ts
+++ b/src/components/exhale-note/proofPages.ts
@@ -1,6 +1,6 @@
// src/components/exhale-note/proofPages.ts
/*
- Proof pages (2–4) renderer
+ Proof pages (2–3) renderer
- Returns an HTML string to drop into your print root (see printer.ts)
- Exports BOTH a named and default export to satisfy either import style
*/
@@ -18,6 +18,7 @@ export interface ProofPagesParams {
phiDerived?: string; // Φ derived from sha256(Σ)
valuePhi: string;
+ valueUsd?: string;
premiumPhi: string;
valuationAlg: string;
valuationStamp?: string;
@@ -27,6 +28,13 @@ export interface ProofPagesParams {
sigilSvg?: string; // raw SVG for page 4 (sanitized)
verifyUrl?: string; // used for QR & link on pages 2/4
+ proofBundleJson?: string;
+ bundleHash?: string;
+ receiptHash?: string;
+ verifiedAtPulse?: number;
+ capsuleHash?: string;
+ svgHash?: string;
+ noteSendJson?: string;
}
/* --- lightweight view helpers matching portal styles --- */
@@ -57,12 +65,53 @@ export function buildProofPagesHTML(p: ProofPagesParams): string {
const phi = p.phiDerived || "";
const val = p.valuePhi || "0";
+ const usd = p.valueUsd || "—";
const prem = p.premiumPhi || "0";
const alg = p.valuationAlg || "phi/kosmos-vφ-5 • 00000000";
const stamp = p.valuationStamp || "";
const verify = p.verifyUrl || "/";
const qrSvg = makeQrSvgTagSafe(verify, 160, 2);
+ const bundleHash = p.bundleHash || "";
+ const receiptHash = p.receiptHash || "";
+ const verifiedAtPulse = p.verifiedAtPulse != null ? String(p.verifiedAtPulse) : "";
+ const capsuleHash = p.capsuleHash || "";
+ const svgHash = p.svgHash || "";
+ const proofBundleJson = p.proofBundleJson || "";
+ const noteSendJson = p.noteSendJson?.trim() || "";
+
+ let noteSendBlock = "";
+ if (noteSendJson) {
+ let parsed: Record | null = null;
+ try {
+ const obj = JSON.parse(noteSendJson) as unknown;
+ if (typeof obj === "object" && obj !== null && !Array.isArray(obj)) {
+ parsed = obj as Record;
+ }
+ } catch {
+ parsed = null;
+ }
+
+ const readStr = (key: string) => (typeof parsed?.[key] === "string" ? String(parsed?.[key]) : "");
+ const readNum = (key: string) =>
+ typeof parsed?.[key] === "number" && Number.isFinite(parsed?.[key] as number) ? String(parsed?.[key]) : "";
+
+ noteSendBlock = `
+ ${cardHead("Note Transfer (Exhale Payload)")}
+ ${kvOpen}
+ ${codeLine("parentCanonical", readStr("parentCanonical") || "—")}
+ ${codeLine("transferNonce", readStr("transferNonce") || "—")}
+ ${codeLine("amountPhi", readNum("amountPhi") || "—")}
+ ${codeLine("amountUsd", readNum("amountUsd") || "—")}
+ ${codeLine("childCanonical", readStr("childCanonical") || "—")}
+ ${kvClose}
+
+ ${hint("Raw note send JSON (embedded for receipt verification)")}
+ ${esc(noteSendJson)}
+
+ ${cardTail}
+ `;
+ }
// Optional ZK block
const zkBlock =
@@ -77,6 +126,22 @@ export function buildProofPagesHTML(p: ProofPagesParams): string {
`
: "";
+ const bundleBlock = `
+ ${cardHead("Verification Bundle")}
+ ${kvOpen}
+ ${codeLine("bundleHash", bundleHash || "—")}
+ ${codeLine("receiptHash", receiptHash || "—")}
+ ${codeLine("verifiedAtPulse", verifiedAtPulse || "—")}
+ ${codeLine("capsuleHash", capsuleHash || "—")}
+ ${codeLine("svgHash", svgHash || "—")}
+ ${kvClose}
+
+ ${hint("Proof bundle JSON (embedded for PDF verification)")}
+ ${esc(proofBundleJson || "—")}
+
+ ${cardTail}
+ `;
+
// Optional provenance table (newest first)
let provDetail = "";
if (p.provenance?.length) {
@@ -141,12 +206,15 @@ export function buildProofPagesHTML(p: ProofPagesParams): string {
${codeLine("Algorithm", alg)}
${codeLine("Valuation Pulse", frozen)}
${codeLine("Value Φ", val)}
+ ${codeLine("Value USD", usd)}
${codeLine("Premium Φ", prem)}
${codeLine("Valuation Stamp", stamp || "—")}
${kvClose}
${cardTail}
+ ${noteSendBlock}
${zkBlock}
+ ${bundleBlock}
${provDetail}
${cardHead("QR • Verify Link (same as seal/QR)")}
@@ -170,34 +238,6 @@ export function buildProofPagesHTML(p: ProofPagesParams): string {
`.trim();
- // PAGE 3 — Attestation (reserved; placeholder)
- const page3 = `
-
-
- PROOF PAGE • Attestation (optional)
- Valuation Pulse: ${esc(frozen)}
-
-
- ${cardHead("Registry Attestation")}
- ${kvOpen}
- ${codeLine("Valid", "—")}
- ${codeLine("r (claim)", "—")}
- ${codeLine("s (signature)", "—")}
- ${codeLine("kid", "—")}
- ${kvClose}
-
- ${hint("Decoded claim JSON")}
- —
-
- ${cardTail}
-
-
- Verifier: offline ECDSA P-256
- PULSE: ${esc(frozen)}
-
-
- `.trim();
-
// PAGE 4 — Raw Sigil SVG (sanitized) + verify link again
const safeSigil = sanitizeSvg(p.sigilSvg || "");
const page4 = `
@@ -227,7 +267,7 @@ export function buildProofPagesHTML(p: ProofPagesParams): string {
`.trim();
- return page2 + page3 + page4;
+ return page2 + page4;
}
/* allow: import buildProofPagesHTML from './proofPages' */
diff --git a/src/components/exhale-note/titles.ts b/src/components/exhale-note/titles.ts
index d770bb493..d680d22d3 100644
--- a/src/components/exhale-note/titles.ts
+++ b/src/components/exhale-note/titles.ts
@@ -23,12 +23,12 @@ export function buildPdfTitle(
/** Main immutable title printed on the banknote header (SVG). */
export const NOTE_TITLE =
-"KAIROS NOTE — LEGAL TENDER OF THE SOVEREIGN KINGDOM";
+"☤KAIROS NOTE — LEGAL TENDER OF THE SOVEREIGN KINGDOM";
/** Optional UI brand line used in app chrome / print header. */
export const UI_BRAND_LINE =
-"KAIROS KURRENSY — Sovereign Harmonik Kingdom";
+"☤KAIROS KURRENSY — Sovereign Harmonik Kingdom";
/** Optional tagline line used under the SVG title. */
export const ISSUANCE_TAGLINE =
-"ISSUED UNDER YAHUAH’S LAW OF ETERNAL LIGHT — Φ • KAI-TURAH";
+"ISSUED UNDER YAHUAH’S LAW OF ETERNAL LIGHT — Φ • ☤KAI-TURAH";
diff --git a/src/components/exhale-note/types.ts b/src/components/exhale-note/types.ts
index a67bbfed5..48769e967 100644
--- a/src/components/exhale-note/types.ts
+++ b/src/components/exhale-note/types.ts
@@ -64,12 +64,38 @@ export type BanknoteInputs = {
// sigil + verify
sigilSvg?: string; // raw SVG for slot
verifyUrl?: string; // used for QR & clickable slot
+
+ // verifier receipt bundle (for PNG embeds/QR payload)
+ proofBundleJson?: string;
+ bundleHash?: string;
+ receiptHash?: string;
+ verifiedAtPulse?: number;
+ capsuleHash?: string;
+ svgHash?: string;
};
export type VerifierBridge = {
getNoteData?: () => BanknoteInputs | Promise;
};
+export type NoteSendPayload = {
+ amountPhi: number;
+ amountPhiScaled: string;
+ amountUsd?: number;
+ lockedPulse: number;
+ valuationStamp: string;
+ transferNonce: string;
+ verifyUrl: string;
+ parentCanonical?: string;
+ childCanonical?: string;
+ senderKaiPulse?: number;
+ senderStamp?: string;
+ previousHeadRoot?: string;
+ transferLeafHashSend?: string;
+};
+
+export type NoteSendResult = NoteSendPayload;
+
export interface NoteProps {
/** Sigil metadata powering live valuation */
meta: SigilMetadataLite;
@@ -82,6 +108,13 @@ export interface NoteProps {
/** Callback fired when user locks the note (Render). */
onRender?: (payload: ExhaleNoteRenderPayload) => void;
+ /** Optional remaining Φ balance for send validation (origin sigil). */
+ availablePhi?: number;
+ /** Optional origin canonical hash for send metadata. */
+ originCanonical?: string;
+ /** Callback fired when user saves a note and it should reserve/send Φ. */
+ onSendNote?: (payload: NoteSendPayload) => Promise | NoteSendResult | void;
+
/** Optional initial builder fields (purpose/to/from/etc.). */
initial?: BanknoteInputs;
className?: string;
diff --git a/src/entry-client.tsx b/src/entry-client.tsx
index 5afa8fa37..3974392ae 100644
--- a/src/entry-client.tsx
+++ b/src/entry-client.tsx
@@ -146,28 +146,18 @@ if (container) {
}
void loadSnarkjsGlobal();
-
/* ─────────────────────────────────────────────────────────────────────
Service worker registration (prod only)
+ - NO auto skipWaiting (prevents mid-session controller swap)
+ - NO update checks while user is typing / KaiVoh active
+ - Manual apply only (window.kairosApplyUpdate)
────────────────────────────────────────────────────────────────────── */
if ("serviceWorker" in navigator && isProduction) {
const registerKairosSW = async () => {
try {
const reg = await navigator.serviceWorker.register(`/sw.js?v=${APP_VERSION}`, { scope: "/" });
- // Avoid mid-session reloads: only refresh when safe/idle.
- let pendingReload = false;
-
- const hasActiveKaiVohSession = (): boolean => {
- try {
- return Boolean(
- window.localStorage.getItem("kai.voh.session.v1") ||
- window.localStorage.getItem("kai.sigilAuth.v1"),
- );
- } catch {
- return false;
- }
- };
+ let pendingApply = false;
const isInteractiveElement = (el: Element | null): boolean => {
if (!el) return false;
@@ -178,16 +168,13 @@ if ("serviceWorker" in navigator && isProduction) {
return false;
};
- const hasFocusedKaiVohField = (): boolean => {
+ const hasFocusedField = (): boolean => {
const active = document.activeElement;
if (!active || active === document.body) return false;
if (!(active instanceof HTMLElement)) return false;
- if (!active.closest(".kai-voh-app-shell, .kai-voh-login-shell, .kai-voh-modal-backdrop")) {
- return false;
- }
return Boolean(
isInteractiveElement(active) ||
- active.closest("input, textarea, select, [contenteditable='true'], [contenteditable='plaintext-only']"),
+ active.closest("input, textarea, select, [contenteditable='true'], [contenteditable]")
);
};
@@ -196,66 +183,81 @@ if ("serviceWorker" in navigator && isProduction) {
document.querySelector(".kai-voh-modal-backdrop") ||
document.querySelector(".kai-voh-app-shell") ||
document.querySelector(".kai-voh-login-shell") ||
- document.querySelector(".kv-post-caption-textarea") ||
document.querySelector(".composer-textarea") ||
- hasFocusedKaiVohField(),
+ hasFocusedField()
);
};
- const isReloadSafe = (): boolean => !hasActiveKaiVohSession() && !hasActiveKaiVohUi();
+ const isReloadSafe = (): boolean => !hasActiveKaiVohUi() && !hasFocusedField();
const markUpdateAvailable = (reason: string): void => {
window.dispatchEvent(
new CustomEvent("kairos-sw-update-available", {
detail: { reason, version: window.kairosSwVersion },
- }),
+ })
);
};
- const tryReload = (reason: string): void => {
- if (!pendingReload) return;
+ const getWaitingWorker = (): ServiceWorker | null => {
+ return reg.waiting || null;
+ };
+
+ const tryApply = (reason: string): void => {
+ if (!pendingApply) return;
if (!isReloadSafe()) {
markUpdateAvailable(`blocked:${reason}`);
return;
}
- if (document.visibilityState === "hidden") {
- window.location.reload();
- } else {
- markUpdateAvailable(`deferred:${reason}`);
+ const w = getWaitingWorker();
+ if (!w) {
+ markUpdateAvailable(`no-waiting:${reason}`);
+ return;
}
- };
-
-
-
- window.kairosApplyUpdate = () => {
- pendingReload = true;
- tryReload("manual");
+ // Step 1: ask waiting SW to activate
+ w.postMessage({ type: "SKIP_WAITING" });
};
- const triggerSkipWaiting = (worker: ServiceWorker | null) => {
- worker?.postMessage({ type: "SKIP_WAITING" });
+ // Manual entrypoint only
+ window.kairosApplyUpdate = () => {
+ pendingApply = true;
+ tryApply("manual");
};
- const watchForUpdates = (registration: ServiceWorkerRegistration) => {
- registration.addEventListener("updatefound", () => {
- const newWorker = registration.installing;
- if (!newWorker) return;
- newWorker.addEventListener("statechange", () => {
- if (newWorker.state === "installed" && navigator.serviceWorker.controller) {
- triggerSkipWaiting(newWorker);
- }
- });
+ // When new SW is installed and waiting, notify UI (do NOT auto-activate)
+ reg.addEventListener("updatefound", () => {
+ const nw = reg.installing;
+ if (!nw) return;
+ nw.addEventListener("statechange", () => {
+ if (nw.state === "installed" && navigator.serviceWorker.controller) {
+ // waiting SW is ready; tell UI an update exists
+ markUpdateAvailable("installed");
+ }
});
- };
+ });
- watchForUpdates(reg);
+ // Controller swap happens AFTER skipWaiting + activate + claim
+ navigator.serviceWorker.addEventListener("controllerchange", () => {
+ // Never reload automatically. If user manually applied, we can reload when safe.
+ if (!pendingApply) return;
+
+ if (!isReloadSafe()) {
+ markUpdateAvailable("controllerchange-blocked");
+ return;
+ }
+
+ // reload only when not visible (zero disruption)
+ if (document.visibilityState === "hidden") {
+ window.location.reload();
+ } else {
+ markUpdateAvailable("controllerchange-deferred");
+ }
+ });
navigator.serviceWorker.addEventListener("message", (event) => {
if (event.data?.type === "SW_ACTIVATED") {
- console.log("Kairos service worker active", event.data.version);
if (typeof event.data.version === "string") {
window.kairosSwVersion = event.data.version;
window.dispatchEvent(new CustomEvent(SW_VERSION_EVENT, { detail: event.data.version }));
@@ -263,27 +265,30 @@ if ("serviceWorker" in navigator && isProduction) {
}
});
- // ✅ Beat cadence update checks (replaces hourly interval)
- const navAny = navigator as Navigator & {
- connection?: { saveData?: boolean; effectiveType?: string };
- };
+ // ✅ Beat cadence update checks — but NEVER while typing / KaiVoh active
+ const navAny = navigator as Navigator & { connection?: { saveData?: boolean; effectiveType?: string } };
const saveData = Boolean(navAny.connection?.saveData);
const effectiveType = navAny.connection?.effectiveType || "";
const slowNet = effectiveType === "slow-2g" || effectiveType === "2g";
+ const safeUpdate = async (): Promise => {
+ if (hasActiveKaiVohUi() || hasFocusedField()) return;
+ await reg.update();
+ };
+
if (saveData || slowNet) {
startKaiCadence({
unit: "beat",
every: 144,
onTick: async () => {
- await reg.update();
+ await safeUpdate();
},
});
} else {
startKaiFibBackoff({
unit: "beat",
work: async () => {
- await reg.update();
+ await safeUpdate();
},
});
}
diff --git a/src/hooks/useDisableZoom.ts b/src/hooks/useDisableZoom.ts
index 8a93d5c19..185a58745 100644
--- a/src/hooks/useDisableZoom.ts
+++ b/src/hooks/useDisableZoom.ts
@@ -1,3 +1,4 @@
+// src/hooks/useDisableZoom.ts
import { useEffect } from "react";
function isInteractiveTarget(t: EventTarget | null): boolean {
@@ -7,11 +8,20 @@ function isInteractiveTarget(t: EventTarget | null): boolean {
if (tag === "input" || tag === "textarea" || tag === "select" || tag === "button") return true;
if (tag === "a") return true;
const ht = el as HTMLElement;
- return Boolean(ht.isContentEditable) || Boolean(el.closest("[contenteditable='true']"));
+ return Boolean(ht.isContentEditable) || Boolean(el.closest("[contenteditable='true'],[contenteditable]"));
+}
+
+function hasEditableFocus(): boolean {
+ const a = typeof document !== "undefined" ? document.activeElement : null;
+ if (!a || !(a instanceof Element)) return false;
+ return isInteractiveTarget(a);
}
/* ──────────────────────────────────────────────────────────────────────────────
- Zoom lock (bridging behavior, no layout impact)
+ Zoom lock (safe)
+ - Blocks pinch zoom + ctrl/cmd zoom
+ - NEVER interferes while typing / focused in inputs
+ - Does NOT mutate html/body touchAction (avoids iOS weirdness)
────────────────────────────────────────────────────────────────────────────── */
export function useDisableZoom(): void {
useEffect(() => {
@@ -25,18 +35,23 @@ export function useDisableZoom(): void {
};
const onTouchEnd = (e: TouchEvent): void => {
+ if (hasEditableFocus()) return;
if (isInteractiveTarget(e.target)) return;
const now = nowTs(e);
+ // double-tap zoom guard (non-interactive areas only)
if (now - lastTouchEnd <= 300) e.preventDefault();
lastTouchEnd = now;
};
const onTouchMove = (e: TouchEvent): void => {
+ if (hasEditableFocus()) return;
+ // prevent pinch zoom (two-finger)
if (e.touches.length > 1) e.preventDefault();
};
const onWheel = (e: WheelEvent): void => {
+ // ctrl/cmd + wheel zoom guard (desktop)
if (e.ctrlKey || e.metaKey) e.preventDefault();
};
@@ -47,23 +62,14 @@ export function useDisableZoom(): void {
};
const onGesture = (e: Event): void => {
+ if (hasEditableFocus()) return;
e.preventDefault();
};
- const html = document.documentElement;
- const body = document.body;
-
- const prevHtmlTouchAction = html.style.touchAction;
- const prevBodyTouchAction = body.style.touchAction;
- const prevTextSizeAdjust =
- (html.style as unknown as { webkitTextSizeAdjust?: string }).webkitTextSizeAdjust;
-
- html.style.touchAction = "manipulation";
- body.style.touchAction = "manipulation";
- (html.style as unknown as { webkitTextSizeAdjust?: string }).webkitTextSizeAdjust = "100%";
-
document.addEventListener("touchend", onTouchEnd, { passive: false, capture: true });
document.addEventListener("touchmove", onTouchMove, { passive: false, capture: true });
+
+ // iOS gesture events (pinch)
document.addEventListener("gesturestart", onGesture, { passive: false, capture: true });
document.addEventListener("gesturechange", onGesture, { passive: false, capture: true });
document.addEventListener("gestureend", onGesture, { passive: false, capture: true });
@@ -80,11 +86,6 @@ export function useDisableZoom(): void {
window.removeEventListener("wheel", onWheel);
window.removeEventListener("keydown", onKeydown);
-
- html.style.touchAction = prevHtmlTouchAction;
- body.style.touchAction = prevBodyTouchAction;
- (html.style as unknown as { webkitTextSizeAdjust?: string }).webkitTextSizeAdjust =
- prevTextSizeAdjust;
};
}, []);
}
diff --git a/src/hooks/useVisualViewportSize.ts b/src/hooks/useVisualViewportSize.ts
index a8c5d77f2..3311c8a4a 100644
--- a/src/hooks/useVisualViewportSize.ts
+++ b/src/hooks/useVisualViewportSize.ts
@@ -1,8 +1,12 @@
+// src/hooks/useVisualViewportSize.ts
import { useEffect, useState } from "react";
/* ──────────────────────────────────────────────────────────────────────────────
- Shared VisualViewport publisher (RAF-throttled)
+ Shared VisualViewport publisher (RAF-throttled, EDIT-FROZEN)
+ - iOS keyboard causes vv resize spam. We allow ONE update after focus, then freeze.
+ - Unfreeze when editing ends, then publish again.
────────────────────────────────────────────────────────────────────────────── */
+
type VVSize = { width: number; height: number };
type VVStore = {
@@ -11,6 +15,11 @@ type VVStore = {
listening: boolean;
rafId: number | null;
cleanup?: (() => void) | null;
+
+ // Freeze behavior during input focus (iOS keyboard churn protection)
+ frozen: boolean;
+ freezePending: boolean;
+ focusCleanup?: (() => void) | null;
};
const vvStore: VVStore = {
@@ -19,8 +28,39 @@ const vvStore: VVStore = {
listening: false,
rafId: null,
cleanup: null,
+
+ frozen: false,
+ freezePending: false,
+ focusCleanup: null,
};
+function isEditableElement(el: Element | null): boolean {
+ if (!el) return false;
+ if (el instanceof HTMLInputElement) return !el.disabled;
+ if (el instanceof HTMLTextAreaElement) return !el.disabled;
+ if (el instanceof HTMLSelectElement) return !el.disabled;
+ if (el instanceof HTMLElement && el.isContentEditable) return true;
+ return false;
+}
+
+function isEditableTarget(t: EventTarget | null): boolean {
+ const el = t instanceof Element ? t : null;
+ if (!el) return false;
+ if (isEditableElement(el)) return true;
+ if (el instanceof HTMLElement) {
+ const nearest = el.closest(
+ "input,textarea,select,[contenteditable='true'],[contenteditable=''],[contenteditable]"
+ );
+ return isEditableElement(nearest);
+ }
+ return false;
+}
+
+function hasEditableFocus(): boolean {
+ if (typeof document === "undefined") return false;
+ return isEditableTarget(document.activeElement);
+}
+
function readVVNow(): VVSize {
if (typeof window === "undefined") return { width: 0, height: 0 };
const vv = window.visualViewport;
@@ -34,38 +74,106 @@ function startVVListeners(): void {
vvStore.listening = true;
vvStore.size = readVVNow();
- const publish = (): void => {
+ const publish = (force = false): void => {
vvStore.rafId = null;
+
+ // If frozen (editing), do nothing unless we're in the “one allowed publish” window.
+ if (vvStore.frozen && !vvStore.freezePending && !force) return;
+
const next = readVVNow();
const prev = vvStore.size;
- if (next.width === prev.width && next.height === prev.height) return;
+
+ if (!force && next.width === prev.width && next.height === prev.height) {
+ // still complete the freeze transition if needed
+ if (vvStore.freezePending) {
+ vvStore.freezePending = false;
+ vvStore.frozen = true;
+ }
+ return;
+ }
+
vvStore.size = next;
vvStore.subs.forEach((fn) => fn(next));
+
+ // After the first keyboard-driven publish, freeze to stop churn while typing.
+ if (vvStore.freezePending) {
+ vvStore.freezePending = false;
+ vvStore.frozen = true;
+ }
};
const schedule = (): void => {
if (vvStore.rafId !== null) return;
- vvStore.rafId = window.requestAnimationFrame(publish);
+ vvStore.rafId = window.requestAnimationFrame(() => publish(false));
+ };
+
+ const scheduleForce = (): void => {
+ if (vvStore.rafId !== null) return;
+ vvStore.rafId = window.requestAnimationFrame(() => publish(true));
};
const vv = window.visualViewport;
+ // Viewport events
window.addEventListener("resize", schedule, { passive: true });
if (vv) {
vv.addEventListener("resize", schedule, { passive: true });
+ // iOS can change vv metrics via scroll when bars/keyboard animate
+ vv.addEventListener("scroll", schedule, { passive: true });
}
+ // Focus freeze logic (prevents iOS keyboard resize spam from re-rendering the whole app)
+ const onFocusIn = (e: FocusEvent): void => {
+ if (!isEditableTarget(e.target)) return;
+
+ // Allow one resize publish after focus, then freeze.
+ vvStore.frozen = false;
+ vvStore.freezePending = true;
+
+ // Force a publish soon even if resize event is delayed.
+ scheduleForce();
+ };
+
+ const onFocusOut = (): void => {
+ // Wait a tick to see if focus just moved to another editable control.
+ window.requestAnimationFrame(() => {
+ if (hasEditableFocus()) return;
+
+ // Editing ended: unfreeze and publish once to restore stable layout.
+ vvStore.frozen = false;
+ vvStore.freezePending = false;
+ scheduleForce();
+ });
+ };
+
+ document.addEventListener("focusin", onFocusIn, true);
+ document.addEventListener("focusout", onFocusOut, true);
+
+ vvStore.focusCleanup = (): void => {
+ document.removeEventListener("focusin", onFocusIn, true);
+ document.removeEventListener("focusout", onFocusOut, true);
+ vvStore.focusCleanup = null;
+ };
+
vvStore.cleanup = (): void => {
if (vvStore.rafId !== null) {
window.cancelAnimationFrame(vvStore.rafId);
vvStore.rafId = null;
}
+
window.removeEventListener("resize", schedule);
if (vv) {
vv.removeEventListener("resize", schedule);
+ vv.removeEventListener("scroll", schedule);
}
+
+ vvStore.focusCleanup?.();
vvStore.cleanup = null;
vvStore.listening = false;
+
+ // reset freeze flags
+ vvStore.frozen = false;
+ vvStore.freezePending = false;
};
}
@@ -82,7 +190,10 @@ export function useVisualViewportSize(): VVSize {
startVVListeners();
- const sub = (s: VVSize): void => setSize(s);
+ const sub = (s: VVSize): void => {
+ setSize((prev) => (prev.width === s.width && prev.height === s.height ? prev : s));
+ };
+
vvStore.subs.add(sub);
sub(readVVNow());
diff --git a/src/og/buildVerifiedCardSvg.ts b/src/og/buildVerifiedCardSvg.ts
index 426d1176a..fe6de31ce 100644
--- a/src/og/buildVerifiedCardSvg.ts
+++ b/src/og/buildVerifiedCardSvg.ts
@@ -7,7 +7,7 @@ import { buildProofOfBreathSeal } from "./proofOfBreathSeal";
export const VERIFIED_CARD_W = 1200;
export const VERIFIED_CARD_H = 630;
-const NOTE_TITLE_TEXT = "KAIROS KURRENCY";
+const NOTE_TITLE_TEXT = "KAIROS NOTE - Legal Tender of ☤KAI";
const NOTE_SUBTITLE_TEXT = "ISSUED UNDER YAHUAH’S LAW OF ETERNAL LIGHT — Φ • KAI-TURAH";
const PHI = (1 + Math.sqrt(5)) / 2;
diff --git a/src/pages/VerifyPage.css b/src/pages/VerifyPage.css
index e1921f5b8..009096980 100644
--- a/src/pages/VerifyPage.css
+++ b/src/pages/VerifyPage.css
@@ -326,6 +326,26 @@ html.verify-shell, body.verify-shell{
.vreceipt-row{ display:flex; align-items:center; justify-content:space-between; gap: 8px; margin-top: 6px; 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; }
+.vnote-claim{
+ font-size: 0.65rem;
+ letter-spacing: 0.2em;
+ padding: 2px 8px;
+ border-radius: 999px;
+ text-transform: uppercase;
+ border: 1px solid rgba(255,255,255,0.2);
+ color: rgba(230,240,255,0.9);
+ background: rgba(18,20,30,0.35);
+}
+.vnote-claim--unclaimed{
+ border-color: rgba(79,197,255,0.6);
+ color: rgba(165,227,255,0.95);
+ background: rgba(23,67,100,0.3);
+}
+.vnote-claim--claimed{
+ border-color: rgba(255,145,145,0.6);
+ color: rgba(255,205,205,0.95);
+ background: rgba(92,33,33,0.35);
+}
.vbtn--ghost{ padding: 8px 12px; border-radius: 999px; font-size: 0.78rem; background: rgba(14,24,36,0.72); border: 1px solid rgba(120,180,255,0.22); color: rgba(230,242,255,0.92); }
.vbtn--ghost:hover{ border-color: rgba(120,200,255,0.45); }
diff --git a/src/pages/VerifyPage.tsx b/src/pages/VerifyPage.tsx
index d9cb772ae..fb5a3d278 100644
--- a/src/pages/VerifyPage.tsx
+++ b/src/pages/VerifyPage.tsx
@@ -1,7 +1,7 @@
// src/pages/VerifyPage.tsx
"use client";
-import React, { useCallback, useMemo, useRef, useState, type ReactElement, type ReactNode } from "react";
+import React, { useCallback, useEffect, 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 } from "../components/VerifierStamper/sigilUtils";
+import { derivePhiKeyFromSig, genNonce } from "../components/VerifierStamper/sigilUtils";
import { tryVerifyGroth16 } from "../components/VerifierStamper/zk";
import { isKASAuthorSig, type KASAuthorSig } from "../utils/authorSig";
import {
@@ -55,8 +55,12 @@ import {
import { assertionToJson, verifyOwnerWebAuthnAssertion } from "../utils/webauthnOwner";
import { deriveOwnerPhiKeyFromReceive, type OwnerKeyDerivation } from "../utils/ownerPhiKey";
import { base64UrlDecode, sha256Hex } from "../utils/sha256";
-import { readPngTextChunk } from "../utils/pngChunks";
+import { insertPngTextChunks, readPngTextChunk } from "../utils/pngChunks";
import { getKaiPulseEternalInt } from "../SovereignSolar";
+import { getSendRecordByNonce, listen, markConfirmedByNonce } from "../utils/sendLedger";
+import { recordSigilTransferMovement } from "../utils/sigilTransferRegistry";
+import { isNoteClaimed, markNoteClaimed } from "../components/SigilExplorer/registryStore";
+import { pullAndImportRemoteUrls } from "../components/SigilExplorer/remotePull";
import { useKaiTicker } from "../hooks/useKaiTicker";
import { useValuation } from "./SigilPage/useValuation";
import type { SigilMetadataLite } from "../utils/valuation";
@@ -64,6 +68,7 @@ import { downloadVerifiedCardPng } from "../og/downloadVerifiedCard";
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 useRollingChartSeries from "../components/VerifierStamper/hooks/useRollingChartSeries";
import { BREATH_MS } from "../components/valuation/constants";
import {
@@ -122,6 +127,44 @@ function parseJsonString(value: unknown): unknown {
}
}
+type NoteSendMeta = {
+ parentCanonical: string;
+ transferNonce: string;
+ amountPhi?: number;
+ amountUsd?: number;
+ childCanonical?: string;
+};
+
+function buildNoteSendMetaFromObject(value: unknown): NoteSendMeta | null {
+ if (!isRecord(value)) return null;
+ const parentCanonical = typeof value.parentCanonical === "string" ? value.parentCanonical.trim() : "";
+ const transferNonce = typeof value.transferNonce === "string" ? value.transferNonce.trim() : "";
+ 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;
+ if (!parentCanonical || !transferNonce) return null;
+ return { parentCanonical, transferNonce, amountPhi, amountUsd, childCanonical };
+}
+
+function parseNoteSendMeta(raw: string | null): NoteSendMeta | null {
+ if (!raw) return null;
+ try {
+ return buildNoteSendMetaFromObject(JSON.parse(raw));
+ } catch {
+ return null;
+ }
+}
+
+function parseNoteSendPayload(raw: string | null): Record | null {
+ if (!raw) return null;
+ try {
+ const parsed = JSON.parse(raw) as unknown;
+ return isRecord(parsed) ? parsed : null;
+ } catch {
+ return null;
+ }
+}
+
function isRecord(value: unknown): value is Record {
return typeof value === "object" && value !== null;
}
@@ -163,7 +206,7 @@ type DebitLoose = {
amount?: number;
};
-type EmbeddedPhiSource = "balance" | "embedded" | "live";
+type EmbeddedPhiSource = "balance" | "embedded" | "live" | "note";
type AttestationState = boolean | "missing";
@@ -450,6 +493,91 @@ function isPngFile(file: File): boolean {
return name.endsWith(".png") || type === "image/png";
}
+function isPdfFile(file: File): boolean {
+ const name = (file.name || "").toLowerCase();
+ const type = (file.type || "").toLowerCase();
+ return name.endsWith(".pdf") || type === "application/pdf";
+}
+
+function extractNearestJson(text: string, anchorIdx: number): unknown | null {
+ let start = -1;
+ let depth = 0;
+ for (let i = anchorIdx; i >= 0; i -= 1) {
+ const ch = text[i];
+ if (ch === "}") depth += 1;
+ if (ch === "{") {
+ if (depth === 0) {
+ start = i;
+ break;
+ }
+ depth -= 1;
+ }
+ }
+ if (start < 0) return null;
+
+ let end = -1;
+ depth = 0;
+ for (let i = start; i < text.length; i += 1) {
+ const ch = text[i];
+ if (ch === "{") depth += 1;
+ if (ch === "}") {
+ depth -= 1;
+ if (depth === 0) {
+ end = i;
+ break;
+ }
+ }
+ }
+ if (end < 0) return null;
+ const raw = text.slice(start, end + 1);
+ try {
+ return JSON.parse(raw) as unknown;
+ } catch {
+ return null;
+ }
+}
+
+function parsePdfForSharedReceipt(buffer: ArrayBuffer): SharedReceipt | null {
+ const decoder = new TextDecoder("latin1");
+ const text = decoder.decode(new Uint8Array(buffer));
+ const anchors = [
+ "\"proofCapsule\"",
+ "\"bundleHash\"",
+ "\"receiptHash\"",
+ "\"verifierUrl\"",
+ "\"verifiedAtPulse\"",
+ "\"ownerPhiKey\"",
+ ];
+
+ for (const anchor of anchors) {
+ let idx = text.indexOf(anchor);
+ while (idx >= 0) {
+ const candidate = extractNearestJson(text, idx);
+ const receipt = buildSharedReceiptFromObject(candidate);
+ if (receipt) return receipt;
+ idx = text.indexOf(anchor, idx + anchor.length);
+ }
+ }
+ return null;
+}
+
+function parsePdfForNoteSendMeta(buffer: ArrayBuffer): NoteSendMeta | null {
+ const decoder = new TextDecoder("latin1");
+ const text = decoder.decode(new Uint8Array(buffer));
+ const anchors = ["\"transferNonce\"", "\"parentCanonical\"", "\"amountPhi\"", "\"amountUsd\"", "\"childCanonical\""];
+
+ for (const anchor of anchors) {
+ let idx = text.indexOf(anchor);
+ while (idx >= 0) {
+ const candidate = extractNearestJson(text, idx);
+ const meta = buildNoteSendMetaFromObject(candidate);
+ if (meta) return meta;
+ idx = text.indexOf(anchor, idx + anchor.length);
+ }
+ }
+ return null;
+}
+
async function copyTextToClipboard(text: string): Promise {
const value = text.trim();
if (!value) return false;
@@ -711,6 +839,8 @@ export default function VerifyPage(): ReactElement {
const fileRef = useRef(null);
const pngFileRef = useRef(null);
const lastAutoScanKeyRef = useRef(null);
+ const noteSendConfirmedRef = useRef(null);
+ const noteClaimRemoteCheckedRef = useRef(null);
const slugRaw = useMemo(() => readSlugFromLocation(), []);
const slug = useMemo(() => parseSlug(slugRaw), [slugRaw]);
@@ -722,6 +852,10 @@ export default function VerifyPage(): ReactElement {
const [result, setResult] = useState({ status: "idle" });
const [busy, setBusy] = useState(false);
const [sharedReceipt, setSharedReceipt] = useState(initialReceiptResult.receipt);
+ const [noteSendMeta, setNoteSendMeta] = useState(null);
+ const [noteSendPayloadRaw, setNoteSendPayloadRaw] = useState | null>(null);
+ const [noteSvgFromPng, setNoteSvgFromPng] = useState("");
+ const [noteProofBundleJson, setNoteProofBundleJson] = useState("");
const [proofCapsule, setProofCapsule] = useState(null);
const [capsuleHash, setCapsuleHash] = useState("");
@@ -758,6 +892,14 @@ export default function VerifyPage(): ReactElement {
const [receiveSig, setReceiveSig] = useState(null);
const [dragActive, setDragActive] = useState(false);
+ const [ledgerTick, setLedgerTick] = useState(0);
+ const [registryTick, setRegistryTick] = useState(0);
+
+ useEffect(() => {
+ return listen(() => {
+ setLedgerTick((prev) => prev + 1);
+ });
+ }, []);
const { pulse: currentPulse } = useKaiTicker();
const searchParams = useMemo(() => new URLSearchParams(typeof window !== "undefined" ? window.location.search : ""), []);
@@ -821,22 +963,61 @@ export default function VerifyPage(): ReactElement {
return readEmbeddedPhiAmount(result.embedded.raw) ?? readEmbeddedPhiAmount(embeddedProof?.raw);
}, [embeddedProof?.raw, result]);
- const displayPhi = ledgerBalance?.remaining ?? embeddedPhi ?? liveValuePhi;
+ const noteValuePhi = noteSendMeta?.amountPhi ?? null;
+ const noteValueUsd = noteSendMeta?.amountUsd ?? null;
+
+ const displayPhi = noteValuePhi ?? ledgerBalance?.remaining ?? embeddedPhi ?? liveValuePhi;
- const displaySource: EmbeddedPhiSource = ledgerBalance ? "balance" : embeddedPhi != null ? "embedded" : "live";
+ const displaySource: EmbeddedPhiSource = noteValuePhi != null ? "note" : ledgerBalance ? "balance" : embeddedPhi != null ? "embedded" : "live";
const displayUsd = useMemo(() => {
+ if (noteValueUsd != null && Number.isFinite(noteValueUsd)) return noteValueUsd;
if (displayPhi == null || !Number.isFinite(usdPerPhi) || usdPerPhi <= 0) return null;
return displayPhi * usdPerPhi;
- }, [displayPhi, usdPerPhi]);
+ }, [displayPhi, noteValueUsd, usdPerPhi]);
- const displayLabel = displaySource === "balance" ? "BALANCE" : displaySource === "embedded" ? "GLYPH" : "LIVE";
+ const displayLabel =
+ displaySource === "note" ? "NOTE" : displaySource === "balance" ? "BALANCE" : displaySource === "embedded" ? "GLYPH" : "LIVE";
const displayAriaLabel =
- displaySource === "balance"
- ? "Glyph balance"
- : displaySource === "embedded"
- ? "Glyph embedded value"
- : "Live glyph valuation";
+ displaySource === "note"
+ ? "Exhale note value"
+ : displaySource === "balance"
+ ? "Glyph balance"
+ : displaySource === "embedded"
+ ? "Glyph embedded value"
+ : "Live glyph valuation";
+
+ const noteSendRecord = useMemo(
+ () => (noteSendMeta ? getSendRecordByNonce(noteSendMeta.parentCanonical, noteSendMeta.transferNonce) : null),
+ [noteSendMeta, 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);
+
+ useEffect(() => {
+ if (!noteSendMeta || noteClaimed) return;
+ const key = `${noteSendMeta.parentCanonical}|${noteSendMeta.transferNonce}`;
+ if (noteClaimRemoteCheckedRef.current === key) return;
+ noteClaimRemoteCheckedRef.current = key;
+ const ac = new AbortController();
+
+ (async () => {
+ try {
+ const res = await pullAndImportRemoteUrls(ac.signal);
+ if (ac.signal.aborted) return;
+ if (res.imported > 0) setRegistryTick((prev) => prev + 1);
+ } catch {
+ // ignore remote registry failures
+ }
+ })();
+
+ return () => {
+ ac.abort();
+ };
+ }, [noteClaimed, noteSendMeta]);
const isReceiveGlyph = useMemo(() => {
const mode = embeddedProof?.mode ?? sharedReceipt?.mode;
@@ -1116,6 +1297,10 @@ export default function VerifyPage(): ReactElement {
setSvgText(text);
setResult({ status: "idle" });
setNotice("");
+ setNoteSendMeta(null);
+ setNoteSendPayloadRaw(null);
+ setNoteSvgFromPng("");
+ setNoteProofBundleJson("");
},
[slug],
);
@@ -1126,13 +1311,20 @@ export default function VerifyPage(): ReactElement {
setResult({ status: "error", message: "Select a receipt PNG with embedded proof metadata.", slug });
return;
}
+ setNoteSendMeta(null);
+ setNoteSendPayloadRaw(null);
+ setNoteSvgFromPng("");
+ setNoteProofBundleJson("");
try {
const buffer = await readFileArrayBuffer(file);
- const text = readPngTextChunk(new Uint8Array(buffer), "phi_proof_bundle");
+ const bytes = new Uint8Array(buffer);
+ const text = readPngTextChunk(bytes, "phi_proof_bundle");
if (!text) {
setResult({ status: "error", message: "Receipt PNG is missing embedded proof metadata.", slug });
return;
}
+ const noteSendJson = readPngTextChunk(bytes, "phi_note_send");
+ const noteSvg = readPngTextChunk(bytes, "phi_note_svg");
const parsed = JSON.parse(text) as unknown;
const receipt = buildSharedReceiptFromObject(parsed);
if (!receipt) {
@@ -1143,6 +1335,10 @@ export default function VerifyPage(): ReactElement {
setSvgText("");
setResult({ status: "idle" });
setNotice("Receipt PNG loaded.");
+ setNoteSendMeta(parseNoteSendMeta(noteSendJson));
+ setNoteSendPayloadRaw(parseNoteSendPayload(noteSendJson));
+ setNoteSvgFromPng(noteSvg ?? "");
+ setNoteProofBundleJson(text);
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to read receipt PNG.";
setResult({ status: "error", message: msg, slug });
@@ -1151,10 +1347,47 @@ export default function VerifyPage(): ReactElement {
[slug],
);
+ const onPickReceiptPdf = useCallback(
+ async (file: File): Promise => {
+ if (!isPdfFile(file)) {
+ setResult({ status: "error", message: "Select a receipt PDF with embedded proof metadata.", slug });
+ return;
+ }
+ setNoteSendMeta(null);
+ setNoteSendPayloadRaw(null);
+ setNoteSvgFromPng("");
+ setNoteProofBundleJson("");
+ try {
+ const buffer = await readFileArrayBuffer(file);
+ const receipt = parsePdfForSharedReceipt(buffer);
+ const noteMeta = parsePdfForNoteSendMeta(buffer);
+ if (!receipt) {
+ setSharedReceipt(null);
+ setResult({ status: "error", message: "Receipt PDF is missing embedded proof metadata.", slug });
+ return;
+ }
+ setSharedReceipt(receipt);
+ setSvgText("");
+ setResult({ status: "idle" });
+ setNotice("Receipt PDF loaded.");
+ setNoteSendMeta(noteMeta);
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : "Failed to read receipt PDF.";
+ setResult({ status: "error", message: msg, slug });
+ }
+ },
+ [slug],
+ );
+
const handleFiles = useCallback(
(files: FileList | null | undefined): void => {
if (!files || files.length === 0) return;
const arr = Array.from(files);
+ const pdf = arr.find(isPdfFile);
+ if (pdf) {
+ void onPickReceiptPdf(pdf);
+ return;
+ }
const png = arr.find(isPngFile);
if (png) {
void onPickReceiptPng(png);
@@ -1167,9 +1400,33 @@ export default function VerifyPage(): ReactElement {
}
void onPickFile(svg);
},
- [onPickFile, onPickReceiptPng, slug],
+ [onPickFile, onPickReceiptPng, onPickReceiptPdf, slug],
);
+ const confirmNoteSend = useCallback(() => {
+ if (!noteSendMeta) return;
+ const key = `${noteSendMeta.parentCanonical}|${noteSendMeta.transferNonce}`;
+ if (noteSendConfirmedRef.current === key) return;
+ noteSendConfirmedRef.current = key;
+ try {
+ markConfirmedByNonce(noteSendMeta.parentCanonical, noteSendMeta.transferNonce);
+ markNoteClaimed(noteSendMeta.parentCanonical, noteSendMeta.transferNonce, {
+ childCanonical: noteSendMeta.childCanonical,
+ });
+ if (noteSendMeta.childCanonical && noteSendMeta.amountPhi) {
+ recordSigilTransferMovement({
+ hash: noteSendMeta.childCanonical,
+ direction: "receive",
+ amountPhi: noteSendMeta.amountPhi,
+ amountUsd: noteSendMeta.amountUsd != null ? noteSendMeta.amountUsd.toFixed(2) : undefined,
+ });
+ }
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.error("note send confirm failed", err);
+ }
+ }, [noteSendMeta]);
+
const runOwnerAuthFlow = useCallback(
async (args: {
ownerAuthorSig: KASAuthorSig | null;
@@ -1357,6 +1614,7 @@ if (receipt.receiptHash) {
embeddedMeta: embeddedProofMeta,
bundleHashValue: embeddedProofMeta?.bundleHash ?? "",
});
+ confirmNoteSend();
} else {
setOwnerAuthVerified(null);
setOwnerAuthStatus("Not present");
@@ -1364,7 +1622,7 @@ if (receipt.receiptHash) {
} finally {
setBusy(false);
}
- }, [currentPulse, runOwnerAuthFlow, slug, stampAuditFields, svgText]);
+ }, [confirmNoteSend, currentPulse, runOwnerAuthFlow, slug, stampAuditFields, svgText]);
const identityAttested: AttestationState = hasKASOwnerSig ? (ownerAuthVerified === null ? "missing" : ownerAuthVerified) : "missing";
@@ -2387,11 +2645,12 @@ if (verified && typeof cacheBundleHash === "string" && cacheBundleHash.trim().le
title: "Proof of Breath™",
status: result.status === "ok" ? "VERIFIED" : result.status === "error" ? "FAILED" : "STANDBY",
body: [
- "Proof of Breath™ is the sovereign attestation that a ΦKey originates from a living human signature rail and that its integrity chain remains unbroken.",
- "This badge is issued only when the inhaled ΦKey, its vessel hash, sigil hash, and attestation bundle collapse into a deterministic, recomputable proof capsule under canonical rules.",
+ "Proof of Breath™ is the sovereign attestation that a ΦKey originates from a living human presence-seal and that its proof stream remains unbroken.",
+ "This badge is issued only when the inhaled ΦKey, its vessel hash, sigil hash, and attestation bundle converge into a deterministic, recomputable proof capsule under canonical rules.",
"No simulacra, no mutable links, no soft checks—only canonicalization, cryptographic determinism, and verifiable coherence.",
],
+
};
}
if (sealPopover === "kas") {
@@ -2704,6 +2963,58 @@ React.useEffect(() => {
zkMeta?.zkPoseidonHash,
]);
+ 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 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 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_svg", text: noteSvgFromPng },
+ ].filter((entry): entry is { keyword: string; text: string } => Boolean(entry));
+
+ if (entries.length === 0) {
+ triggerDownload(filename, png, "image/png");
+ confirmNoteSend();
+ return;
+ }
+
+ 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]);
+
const onDownloadVerifiedCard = useCallback(async () => {
if (!verifiedCardData) return;
await downloadVerifiedCardPng(verifiedCardData);
@@ -2955,6 +3266,14 @@ React.useEffect(() => {
{proofCapsule ? (
Proof
+ {noteClaimStatus ? (
+
+ {noteClaimStatus}
+
+ ) : null}
void onShareReceipt()}>
➦
@@ -2962,19 +3281,34 @@ React.useEffect(() => {
void onCopyReceipt()}>
💠
- void onSignVerification()}
- title={verificationSigLabel}
- aria-label={verificationSigLabel}
- disabled={!canSignVerification || verificationSigBusy}
- >
- ✍
-
- void onDownloadVerifiedCard()}>
- ⬇
-
+ {isExhaleNoteUpload ? null : (
+ void onSignVerification()}
+ title={verificationSigLabel}
+ aria-label={verificationSigLabel}
+ disabled={!canSignVerification || verificationSigBusy}
+ >
+ ✍
+
+ )}
+ {noteSvgFromPng && result.status === "ok" && !noteClaimed ? (
+
+ ⬇︎Φ
+
+ ) : null}
+ {isExhaleNoteUpload ? null : (
+ void onDownloadVerifiedCard()}>
+ ⬇
+
+ )}
@@ -3077,7 +3411,7 @@ React.useEffect(() => {
ref={pngFileRef}
className="vfile"
type="file"
- accept=".png,image/png"
+ accept=".png,image/png,.pdf,application/pdf"
onChange={(e) => {
handleFiles(e.currentTarget.files);
e.currentTarget.value = "";
@@ -3411,13 +3745,29 @@ React.useEffect(() => {
{result.status === "ok" && displayPhi != null ? (
openChartPopover("phi")}
ariaLabel="Open live chart for Φ value"
/>
openChartPopover("usd")}
ariaLabel="Open live chart for USD value"
diff --git a/src/utils/sendLedger.ts b/src/utils/sendLedger.ts
index cd1462fd0..9ffbf6cdd 100644
--- a/src/utils/sendLedger.ts
+++ b/src/utils/sendLedger.ts
@@ -281,6 +281,19 @@ export function markConfirmedByNonce(parentCanonical: string, transferNonce: str
}
}
+/** Lookup a send record by parent canonical + transfer nonce. */
+export function getSendRecordByNonce(parentCanonical: string, transferNonce: string): SendRecord | null {
+ const pc = lc(parentCanonical);
+ const nonce = String(transferNonce || "");
+ if (!pc || !nonce) return null;
+ const list = readAll();
+ for (let i = list.length - 1; i >= 0; i -= 1) {
+ const rec = list[i];
+ if (rec.parentCanonical === pc && rec.transferNonce === nonce) return rec;
+ }
+ return null;
+}
+
/** Get all sends for a given parent canonical (sorted by createdAt ASC). */
export function getSendsFor(parentCanonical: string): SendRecord[] {
const pc = lc(parentCanonical);
@@ -296,6 +309,12 @@ export function getSpentScaledFor(parentCanonical: string): bigint {
);
}
+/** Sum of all Φ (scaled) reserved/exhaled from a parent canonical (confirmed + pending). */
+export function getReservedScaledFor(parentCanonical: string): bigint {
+ const rows = getSendsFor(parentCanonical);
+ return rows.reduce((acc, r) => acc + BigInt(coerceBigIntString(r.amountPhiScaled)), 0n);
+}
+
/**
* Incoming base Φ for a CHILD canonical.
* Returns the most recent amount exhaled *to* this child, as scaled BigInt.
diff --git a/src/utils/valuationSnapshot.ts b/src/utils/valuationSnapshot.ts
index c78dab730..f4ff49d01 100644
--- a/src/utils/valuationSnapshot.ts
+++ b/src/utils/valuationSnapshot.ts
@@ -4,7 +4,7 @@ export type ValuationSnapshot = Readonly<{
phiValue: number;
usdValue: number | null;
usdPerPhi: number | null;
- source: "balance" | "embedded" | "live" | "unknown";
+ source: "balance" | "embedded" | "live" | "note" | "unknown";
mode: "origin" | "receive";
}>;
@@ -35,7 +35,14 @@ export function isValuationSnapshot(value: unknown): value is ValuationSnapshot
if (usdPerPhi !== null && !isFiniteNumber(usdPerPhi)) return false;
const usdValue = record.usdValue;
if (usdValue !== null && !isFiniteNumber(usdValue)) return false;
- if (record.source !== "balance" && record.source !== "embedded" && record.source !== "live" && record.source !== "unknown") return false;
+ if (
+ record.source !== "balance" &&
+ record.source !== "embedded" &&
+ record.source !== "live" &&
+ record.source !== "note" &&
+ record.source !== "unknown"
+ )
+ return false;
if (record.mode !== "origin" && record.mode !== "receive") return false;
return true;
}
diff --git a/src/version.ts b/src/version.ts
index bd05b4954..aba4ea290 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.4.0"; // Canonical offline/PWA version
+export const BASE_APP_VERSION = "42.7.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 =
diff --git a/vercel.json b/vercel.json
index 735f4e9bf..12d85124f 100644
--- a/vercel.json
+++ b/vercel.json
@@ -31,6 +31,11 @@
{ "source": "/sw.js", "destination": "/sw.js" },
{ "source": "/service-worker.js", "destination": "/service-worker.js" },
+ { "source": "/voh", "destination": "/api/ssr" },
+ { "source": "/voh/(.*)", "destination": "/api/ssr" },
+ { "source": "/kaivoh", "destination": "/api/ssr" },
+ { "source": "/kaivoh/(.*)", "destination": "/api/ssr" },
+
{ "source": "/", "destination": "/api/ssr" },
{
"source": "/:path((?!api/).*)",
- Kairos Notes are legal tender — sealed by Proof of Breath™, pulsed by Kai-Signature™, + ☤Kairos Notes are legal tender — sealed by Proof of Breath™, pulsed by Kai-Signature™, auditable as: Σ → SHA-256(Σ) → Φ.
diff --git a/src/components/VerifierStamper/VerifierStamper.tsx b/src/components/VerifierStamper/VerifierStamper.tsx index 715e17d7c..bac9265be 100644 --- a/src/components/VerifierStamper/VerifierStamper.tsx +++ b/src/components/VerifierStamper/VerifierStamper.tsx @@ -75,7 +75,7 @@ import SealMomentModal from "../SealMomentModalTransfer"; import ValuationModal from "../ValuationModal"; import { buildValueSeal, attachValuation, type ValueSeal } from "../../utils/valuation"; import NotePrinter from "../ExhaleNote"; -import type { BanknoteInputs as NoteBanknoteInputs, VerifierBridge } from "../exhale-note/types"; +import type { BanknoteInputs as NoteBanknoteInputs, NoteSendPayload, NoteSendResult, VerifierBridge } from "../exhale-note/types"; import { kaiPulseNow, SIGIL_CTX, SIGIL_TYPE, SEGMENT_SIZE } from "./constants"; import { sha256Hex, phiFromPublicKey } from "./crypto"; @@ -99,7 +99,7 @@ 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, getSpentScaledFor, markConfirmedByLeaf } from "../../utils/sendLedger"; +import { recordSend, getReservedScaledFor, markConfirmedByLeaf } from "../../utils/sendLedger"; import { recordSigilTransferMovement } from "../../utils/sigilTransferRegistry"; import { buildBundleRoot, @@ -647,26 +647,73 @@ const VerifierStamperInner: React.FC = () => { }; const noteInitial = useMemo${esc(noteSendJson)}
+ ${esc(proofBundleJson || "—")}
+ —-