From c94d3c7b81465917f3ed54c2db9004ab08d399b4 Mon Sep 17 00:00:00 2001 From: Kojib Date: Tue, 27 Jan 2026 05:21:50 -0500 Subject: [PATCH 01/60] v42.4.1 --- public/sw.js | 2 +- src/og/buildVerifiedCardSvg.ts | 2 +- src/version.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/public/sw.js b/public/sw.js index 2825fdb4..a7e8025f 100644 --- a/public/sw.js +++ b/public/sw.js @@ -14,7 +14,7 @@ // Update this version string manually to keep the app + cache versions in sync. // The value is forwarded to the UI via the service worker "SW_ACTIVATED" message. -const APP_VERSION = "42.4.0"; // update on release +const APP_VERSION = "42.4.1"; // update on release const VERSION = new URL(self.location.href).searchParams.get("v") || APP_VERSION; // derived from build const PREFIX = "PHINETWORK"; diff --git a/src/og/buildVerifiedCardSvg.ts b/src/og/buildVerifiedCardSvg.ts index 426d1176..388f55f9 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"; 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/version.ts b/src/version.ts index bd05b495..ea6bcdbe 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.4.1"; // 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 = From a1c28c815509bbcbfb7438a01fee4196dfbbea5f Mon Sep 17 00:00:00 2001 From: Kojib Date: Tue, 27 Jan 2026 05:31:16 -0500 Subject: [PATCH 02/60] v42.4.2 --- public/sw.js | 2 +- src/og/buildVerifiedCardSvg.ts | 2 +- src/version.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/public/sw.js b/public/sw.js index a7e8025f..939e0721 100644 --- a/public/sw.js +++ b/public/sw.js @@ -14,7 +14,7 @@ // Update this version string manually to keep the app + cache versions in sync. // The value is forwarded to the UI via the service worker "SW_ACTIVATED" message. -const APP_VERSION = "42.4.1"; // update on release +const APP_VERSION = "42.4.2"; // update on release const VERSION = new URL(self.location.href).searchParams.get("v") || APP_VERSION; // derived from build const PREFIX = "PHINETWORK"; diff --git a/src/og/buildVerifiedCardSvg.ts b/src/og/buildVerifiedCardSvg.ts index 388f55f9..fe6de31c 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 NOTE"; +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/version.ts b/src/version.ts index ea6bcdbe..0cbbf3a6 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.1"; // Canonical offline/PWA version +export const BASE_APP_VERSION = "42.4.2"; // 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 = From f75d295f6df841e8c72b3de19616b6d32a40cb1b Mon Sep 17 00:00:00 2001 From: Kojib Date: Tue, 27 Jan 2026 05:43:52 -0500 Subject: [PATCH 03/60] v42.4.3 --- public/sw.js | 2 +- src/pages/VerifyPage.tsx | 5 +++-- src/version.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/public/sw.js b/public/sw.js index 939e0721..1bd56af1 100644 --- a/public/sw.js +++ b/public/sw.js @@ -14,7 +14,7 @@ // Update this version string manually to keep the app + cache versions in sync. // The value is forwarded to the UI via the service worker "SW_ACTIVATED" message. -const APP_VERSION = "42.4.2"; // update on release +const APP_VERSION = "42.4.3"; // update on release const VERSION = new URL(self.location.href).searchParams.get("v") || APP_VERSION; // derived from build const PREFIX = "PHINETWORK"; diff --git a/src/pages/VerifyPage.tsx b/src/pages/VerifyPage.tsx index d9cb772a..07cca8fc 100644 --- a/src/pages/VerifyPage.tsx +++ b/src/pages/VerifyPage.tsx @@ -2387,11 +2387,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") { diff --git a/src/version.ts b/src/version.ts index 0cbbf3a6..e0d2dadd 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.2"; // Canonical offline/PWA version +export const BASE_APP_VERSION = "42.4.3"; // 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 = From 41d01717f3b18691f06a2259125e26f9d85becfc Mon Sep 17 00:00:00 2001 From: Kojib <123880127+kojibai@users.noreply.github.com> Date: Fri, 30 Jan 2026 12:43:09 -0500 Subject: [PATCH 04/60] Prevent KaiVoh pull-to-refresh during input focus --- src/components/KaiVoh/KaiVohModal.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/components/KaiVoh/KaiVohModal.tsx b/src/components/KaiVoh/KaiVohModal.tsx index 2d80b955..b9a42394 100644 --- a/src/components/KaiVoh/KaiVohModal.tsx +++ b/src/components/KaiVoh/KaiVohModal.tsx @@ -279,6 +279,20 @@ export default function KaiVohModal({ open, onClose }: KaiVohModalProps) { // Touch gating (iOS pull-to-refresh prevention) const onTouchStart = (e: TouchEvent): void => { touchStartYRef.current = e.touches[0]?.clientY ?? 0; + + const s = scrollRegionRef.current; + if (!s) return; + + const target = e.target as Node | null; + const insideScrollRegion = target ? s.contains(target) : false; + if (!insideScrollRegion) return; + + // iOS pull-to-refresh guard: nudge scroll position away from edges + if (s.scrollTop <= 0) { + s.scrollTop = 1; + } else if (s.scrollTop + s.clientHeight >= s.scrollHeight) { + s.scrollTop = Math.max(0, s.scrollHeight - s.clientHeight - 1); + } }; const onTouchMove = (e: TouchEvent): void => { From 5bc91c55303d357270c5268f904cc7f0c29f08bb Mon Sep 17 00:00:00 2001 From: Kojib <123880127+kojibai@users.noreply.github.com> Date: Fri, 30 Jan 2026 12:46:48 -0500 Subject: [PATCH 05/60] Disable pull-to-refresh in KaiVoh modal --- src/components/KaiVoh/KaiVohModal.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/KaiVoh/KaiVohModal.tsx b/src/components/KaiVoh/KaiVohModal.tsx index b9a42394..536ea46a 100644 --- a/src/components/KaiVoh/KaiVohModal.tsx +++ b/src/components/KaiVoh/KaiVohModal.tsx @@ -226,6 +226,7 @@ export default function KaiVohModal({ open, onClose }: KaiVohModalProps) { htmlHeight: document.documentElement.style.height, docOverscroll: document.documentElement.style.getPropertyValue("overscroll-behavior"), bodyOverscroll: document.body.style.getPropertyValue("overscroll-behavior"), + bodyTouchAction: document.body.style.touchAction, touchAction: document.documentElement.style.touchAction, breath: document.documentElement.style.getPropertyValue("--kai-breath"), phi: document.documentElement.style.getPropertyValue("--kai-phi"), @@ -250,7 +251,8 @@ export default function KaiVohModal({ open, onClose }: KaiVohModalProps) { document.body.style.setProperty("overscroll-behavior", "none"); // Avoid double-tap zoom / weird pan interactions outside our scroll region - document.documentElement.style.touchAction = "manipulation"; + document.documentElement.style.touchAction = "none"; + document.body.style.touchAction = "none"; // CSS vars for timing/phi document.documentElement.style.setProperty("--kai-breath", viewportVars.breath); @@ -453,6 +455,7 @@ export default function KaiVohModal({ open, onClose }: KaiVohModalProps) { else document.body.style.removeProperty("overscroll-behavior"); document.documentElement.style.touchAction = prev.touchAction; + document.body.style.touchAction = prev.bodyTouchAction; if (prev.breath) document.documentElement.style.setProperty("--kai-breath", prev.breath); else document.documentElement.style.removeProperty("--kai-breath"); From 45770e4cc663a58c8c0b63dd7336588ffd15caed Mon Sep 17 00:00:00 2001 From: Kojib Date: Fri, 30 Jan 2026 12:48:52 -0500 Subject: [PATCH 06/60] v42.4.4 --- public/sw.js | 2 +- src/version.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/sw.js b/public/sw.js index 1bd56af1..91de2f25 100644 --- a/public/sw.js +++ b/public/sw.js @@ -14,7 +14,7 @@ // Update this version string manually to keep the app + cache versions in sync. // The value is forwarded to the UI via the service worker "SW_ACTIVATED" message. -const APP_VERSION = "42.4.3"; // update on release +const APP_VERSION = "42.4.4"; // update on release const VERSION = new URL(self.location.href).searchParams.get("v") || APP_VERSION; // derived from build const PREFIX = "PHINETWORK"; diff --git a/src/version.ts b/src/version.ts index e0d2dadd..e13f1e4e 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.3"; // Canonical offline/PWA version +export const BASE_APP_VERSION = "42.4.4"; // 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 = From d5bd9a817269a61a2cfcf37d8ba24a436aa8e11f Mon Sep 17 00:00:00 2001 From: Kojib <123880127+kojibai@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:17:42 -0500 Subject: [PATCH 07/60] Block reloads while KaiVoh modal is open --- src/components/KaiVoh/KaiVohModal.tsx | 39 +++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/components/KaiVoh/KaiVohModal.tsx b/src/components/KaiVoh/KaiVohModal.tsx index 536ea46a..77e97049 100644 --- a/src/components/KaiVoh/KaiVohModal.tsx +++ b/src/components/KaiVoh/KaiVohModal.tsx @@ -212,6 +212,12 @@ export default function KaiVohModal({ open, onClose }: KaiVohModalProps) { useEffect(() => { if (!open) return; const allowEscapeClose = !window.matchMedia?.("(pointer: coarse)")?.matches; + const locationAny = window.location as unknown as Record void>; + const originalLocation = { + reload: window.location.reload?.bind(window.location), + assign: window.location.assign?.bind(window.location), + replace: window.location.replace?.bind(window.location), + }; // Save prior styles (restore exactly) const prev = { @@ -258,6 +264,24 @@ export default function KaiVohModal({ open, onClose }: KaiVohModalProps) { document.documentElement.style.setProperty("--kai-breath", viewportVars.breath); document.documentElement.style.setProperty("--kai-phi", viewportVars.phi); + // HARD: block programmatic reload/replace/assign while modal is open + const blockNav = (label: string) => { + return (...args: unknown[]) => { + if (isReloadDebugEnabled()) { + // eslint-disable-next-line no-console + console.warn(`[Reload Detective] blocked ${label}`, ...args); + } + }; + }; + + try { + if (originalLocation.reload) locationAny.reload = blockNav("location.reload"); + if (originalLocation.assign) locationAny.assign = blockNav("location.assign"); + if (originalLocation.replace) locationAny.replace = blockNav("location.replace"); + } catch { + // ignore inability to patch location methods + } + // Stable viewport var (helps iOS address-bar / orientation “jump”) const syncVh = (): void => { document.documentElement.style.setProperty("--kai-vh", `${window.innerHeight}px`); @@ -363,6 +387,12 @@ export default function KaiVohModal({ open, onClose }: KaiVohModalProps) { return; } + if (e.key === "F5" || ((e.key === "r" || e.key === "R") && (e.ctrlKey || e.metaKey))) { + e.preventDefault(); + e.stopPropagation(); + return; + } + if (e.key === "Enter") { const target = e.target as Element | null; if (shouldSuppressEnter(target) && rootRef.current?.contains(target)) { @@ -466,6 +496,15 @@ export default function KaiVohModal({ open, onClose }: KaiVohModalProps) { if (prev.kaiVh) document.documentElement.style.setProperty("--kai-vh", prev.kaiVh); else document.documentElement.style.removeProperty("--kai-vh"); + // Restore location methods + try { + if (originalLocation.reload) locationAny.reload = originalLocation.reload; + if (originalLocation.assign) locationAny.assign = originalLocation.assign; + if (originalLocation.replace) locationAny.replace = originalLocation.replace; + } catch { + // ignore restore failures + } + // Restore scroll position after unlocking fixed body const y = lockedScrollYRef.current || 0; window.scrollTo(0, y); From 1c7a894da6e66b7b7a52fad63de401f5ed5f57e3 Mon Sep 17 00:00:00 2001 From: Kojib <123880127+kojibai@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:25:06 -0500 Subject: [PATCH 08/60] Fix KaiVoh reload guard typing --- src/components/KaiVoh/KaiVohModal.tsx | 41 ++++++++++++++++----------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/src/components/KaiVoh/KaiVohModal.tsx b/src/components/KaiVoh/KaiVohModal.tsx index 77e97049..68ab2eec 100644 --- a/src/components/KaiVoh/KaiVohModal.tsx +++ b/src/components/KaiVoh/KaiVohModal.tsx @@ -212,11 +212,11 @@ export default function KaiVohModal({ open, onClose }: KaiVohModalProps) { useEffect(() => { if (!open) return; const allowEscapeClose = !window.matchMedia?.("(pointer: coarse)")?.matches; - const locationAny = window.location as unknown as Record void>; + const locationAny = window.location as Location; const originalLocation = { - reload: window.location.reload?.bind(window.location), - assign: window.location.assign?.bind(window.location), - replace: window.location.replace?.bind(window.location), + reload: window.location.reload.bind(window.location), + assign: window.location.assign.bind(window.location), + replace: window.location.replace.bind(window.location), }; // Save prior styles (restore exactly) @@ -265,19 +265,26 @@ export default function KaiVohModal({ open, onClose }: KaiVohModalProps) { document.documentElement.style.setProperty("--kai-phi", viewportVars.phi); // HARD: block programmatic reload/replace/assign while modal is open - const blockNav = (label: string) => { - return (...args: unknown[]) => { - if (isReloadDebugEnabled()) { - // eslint-disable-next-line no-console - console.warn(`[Reload Detective] blocked ${label}`, ...args); - } - }; + const logBlocked = (label: string, args: unknown[]): void => { + if (!isReloadDebugEnabled()) return; + // eslint-disable-next-line no-console + console.warn(`[Reload Detective] blocked ${label}`, ...args); + }; + + const blockReload: Location["reload"] = (...args) => { + logBlocked("location.reload", args); + }; + const blockAssign: Location["assign"] = (url) => { + logBlocked("location.assign", [url]); + }; + const blockReplace: Location["replace"] = (url) => { + logBlocked("location.replace", [url]); }; try { - if (originalLocation.reload) locationAny.reload = blockNav("location.reload"); - if (originalLocation.assign) locationAny.assign = blockNav("location.assign"); - if (originalLocation.replace) locationAny.replace = blockNav("location.replace"); + locationAny.reload = blockReload; + locationAny.assign = blockAssign; + locationAny.replace = blockReplace; } catch { // ignore inability to patch location methods } @@ -498,9 +505,9 @@ export default function KaiVohModal({ open, onClose }: KaiVohModalProps) { // Restore location methods try { - if (originalLocation.reload) locationAny.reload = originalLocation.reload; - if (originalLocation.assign) locationAny.assign = originalLocation.assign; - if (originalLocation.replace) locationAny.replace = originalLocation.replace; + locationAny.reload = originalLocation.reload; + locationAny.assign = originalLocation.assign; + locationAny.replace = originalLocation.replace; } catch { // ignore restore failures } From b953ab49825d69a39a25ad4dcd28101a833b1a13 Mon Sep 17 00:00:00 2001 From: Kojib Date: Fri, 30 Jan 2026 14:16:54 -0500 Subject: [PATCH 09/60] v42.4.5 --- public/sw.js | 2 +- src/version.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/sw.js b/public/sw.js index 91de2f25..ecd99775 100644 --- a/public/sw.js +++ b/public/sw.js @@ -14,7 +14,7 @@ // Update this version string manually to keep the app + cache versions in sync. // The value is forwarded to the UI via the service worker "SW_ACTIVATED" message. -const APP_VERSION = "42.4.4"; // update on release +const APP_VERSION = "42.4.5"; // update on release const VERSION = new URL(self.location.href).searchParams.get("v") || APP_VERSION; // derived from build const PREFIX = "PHINETWORK"; diff --git a/src/version.ts b/src/version.ts index e13f1e4e..8b9d83c2 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.4"; // Canonical offline/PWA version +export const BASE_APP_VERSION = "42.4.5"; // 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 = From edcb6bb9af3d9b82df09bb5c2ea6deaac66bd9cf Mon Sep 17 00:00:00 2001 From: Kojib <123880127+kojibai@users.noreply.github.com> Date: Fri, 30 Jan 2026 14:27:52 -0500 Subject: [PATCH 10/60] Add Kaivoh SSR rewrites --- vercel.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/vercel.json b/vercel.json index 735f4e9b..12d85124 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/).*)", From 4d16d30d87bc8de0ab8ffd266334f401d50333bc Mon Sep 17 00:00:00 2001 From: Kojib Date: Fri, 30 Jan 2026 14:32:14 -0500 Subject: [PATCH 11/60] v42.4.6 --- public/sw.js | 2 +- src/version.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/sw.js b/public/sw.js index ecd99775..9518156c 100644 --- a/public/sw.js +++ b/public/sw.js @@ -14,7 +14,7 @@ // Update this version string manually to keep the app + cache versions in sync. // The value is forwarded to the UI via the service worker "SW_ACTIVATED" message. -const APP_VERSION = "42.4.5"; // update on release +const APP_VERSION = "42.4.6"; // update on release const VERSION = new URL(self.location.href).searchParams.get("v") || APP_VERSION; // derived from build const PREFIX = "PHINETWORK"; diff --git a/src/version.ts b/src/version.ts index 8b9d83c2..a2f6abe3 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.5"; // Canonical offline/PWA version +export const BASE_APP_VERSION = "42.4.6"; // 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 = From 24f7b679b2b65e2bec115fcccaf7590dd54a7619 Mon Sep 17 00:00:00 2001 From: Kojib Date: Fri, 30 Jan 2026 14:47:15 -0500 Subject: [PATCH 12/60] v42.4.7 --- public/sw.js | 2 +- src/components/KaiVoh/KaiVohModal.tsx | 117 +++++++++++++++---- src/components/KaiVoh/styles/KaiVohModal.css | 37 ++---- src/version.ts | 2 +- 4 files changed, 110 insertions(+), 48 deletions(-) diff --git a/public/sw.js b/public/sw.js index 9518156c..3e15c863 100644 --- a/public/sw.js +++ b/public/sw.js @@ -14,7 +14,7 @@ // Update this version string manually to keep the app + cache versions in sync. // The value is forwarded to the UI via the service worker "SW_ACTIVATED" message. -const APP_VERSION = "42.4.6"; // update on release +const APP_VERSION = "42.4.7"; // update on release const VERSION = new URL(self.location.href).searchParams.get("v") || APP_VERSION; // derived from build const PREFIX = "PHINETWORK"; diff --git a/src/components/KaiVoh/KaiVohModal.tsx b/src/components/KaiVoh/KaiVohModal.tsx index 68ab2eec..8ec6067b 100644 --- a/src/components/KaiVoh/KaiVohModal.tsx +++ b/src/components/KaiVoh/KaiVohModal.tsx @@ -88,12 +88,7 @@ function SigilAuthPill({ className }: { className?: string }) { const meta = auth.meta; if (!meta) return null; - const titleParts: string[] = [ - `Pulse: ${meta.pulse}`, - `Beat: ${meta.beat}`, - `Step: ${meta.stepIndex}`, - `Day: ${meta.chakraDay}`, - ]; + const titleParts: string[] = [`Pulse: ${meta.pulse}`, `Beat: ${meta.beat}`, `Step: ${meta.stepIndex}`, `Day: ${meta.chakraDay}`]; if (meta.sigilId) titleParts.push(`Sigil: ${meta.sigilId}`); if (meta.userPhiKey) titleParts.push(`PhiKey: ${meta.userPhiKey}`); @@ -148,6 +143,18 @@ function isEditableElement(el: Element | null): boolean { return false; } +/** Treat events on descendants of editable controls as editable too. */ +function isEditableTarget(target: EventTarget | null): boolean { + const el = target instanceof Element ? target : 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 shouldSuppressEnter(el: Element | null): boolean { if (!el) return false; if (el instanceof HTMLTextAreaElement) return false; @@ -207,10 +214,11 @@ export default function KaiVohModal({ open, onClose }: KaiVohModalProps) { * - Prevents any touchmove/wheel outside the modal scroll region. * - Prevents overscroll bounce at bounds INSIDE the modal scroll region (iOS pull-to-refresh trigger). * - Adds Escape-to-close + Tab focus trap (avoids focus escape → accidental page scroll). - * - Sets global CSS vars for breath/phi + an innerHeight var for stable layout. + * - Sets global CSS vars for breath/phi + a VisualViewport-stable --kai-vh. */ useEffect(() => { if (!open) return; + const allowEscapeClose = !window.matchMedia?.("(pointer: coarse)")?.matches; const locationAny = window.location as Location; const originalLocation = { @@ -256,9 +264,8 @@ export default function KaiVohModal({ open, onClose }: KaiVohModalProps) { document.documentElement.style.setProperty("overscroll-behavior", "none"); document.body.style.setProperty("overscroll-behavior", "none"); - // Avoid double-tap zoom / weird pan interactions outside our scroll region - document.documentElement.style.touchAction = "none"; - document.body.style.touchAction = "none"; + // ✅ CRITICAL FIX: do NOT set html/body touch-action:none + // (It breaks iOS keyboard + editable gestures and looks like a refresh.) // CSS vars for timing/phi document.documentElement.style.setProperty("--kai-breath", viewportVars.breath); @@ -289,17 +296,72 @@ export default function KaiVohModal({ open, onClose }: KaiVohModalProps) { // ignore inability to patch location methods } - // Stable viewport var (helps iOS address-bar / orientation “jump”) - const syncVh = (): void => { - document.documentElement.style.setProperty("--kai-vh", `${window.innerHeight}px`); + // ✅ KEY FIX: VisualViewport-stable --kai-vh, freeze during editing + const vv = window.visualViewport ?? null; + + let rafId = 0; + let lastH = -1; + + let freezeVh = false; + let freezePending = false; + + const measureVh = (): number => { + const h = vv?.height ?? window.innerHeight; + return Math.max(1, Math.round(h)); + }; + + const applyVh = (force = false): void => { + const h = measureVh(); + if (!force && lastH >= 0 && Math.abs(h - lastH) < 2) return; + lastH = h; + document.documentElement.style.setProperty("--kai-vh", `${h}px`); + }; + + const scheduleVh = (force = false): void => { + if (rafId) cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(() => { + rafId = 0; + applyVh(force); + + // After the first keyboard-driven resize after focus, freeze to prevent jitter while typing. + if (freezePending) { + freezePending = false; + freezeVh = true; + } + }); + }; + + const onViewportResize = (): void => { + if (freezeVh) return; + scheduleVh(false); + }; + + const onFocusIn = (e: FocusEvent): void => { + if (!isEditableTarget(e.target)) return; + // allow one viewport update to capture keyboard height, then freeze + freezeVh = false; + freezePending = true; + scheduleVh(true); + }; + + const onFocusOut = (e: FocusEvent): void => { + if (!isEditableTarget(e.target)) return; + // keyboard closing: unfreeze so vh can restore + freezeVh = false; + freezePending = false; + scheduleVh(true); }; - syncVh(); - window.addEventListener("resize", syncVh, { passive: true }); + + scheduleVh(true); + window.addEventListener("resize", onViewportResize, { passive: true }); + vv?.addEventListener("resize", onViewportResize as EventListener, { passive: true } as AddEventListenerOptions); + vv?.addEventListener("scroll", onViewportResize as EventListener, { passive: true } as AddEventListenerOptions); + document.addEventListener("focusin", onFocusIn as EventListener, { capture: true, passive: true }); + document.addEventListener("focusout", onFocusOut as EventListener, { capture: true, passive: true }); // Make the scroll region itself “contain” overscroll (best-effort; iOS still needs touch gate) const scrollEl = scrollRegionRef.current; if (scrollEl) { - scrollEl.style.overscrollBehavior = "contain"; // iOS momentum scrolling (smooth) // @ts-expect-error: webkitOverflowScrolling not in standard types. @@ -311,6 +373,9 @@ export default function KaiVohModal({ open, onClose }: KaiVohModalProps) { // Touch gating (iOS pull-to-refresh prevention) const onTouchStart = (e: TouchEvent): void => { + // ✅ never interfere with focusing/typing/selection inside inputs + if (isEditableTarget(e.target)) return; + touchStartYRef.current = e.touches[0]?.clientY ?? 0; const s = scrollRegionRef.current; @@ -331,6 +396,9 @@ export default function KaiVohModal({ open, onClose }: KaiVohModalProps) { const onTouchMove = (e: TouchEvent): void => { if (e.touches.length !== 1) return; + // ✅ never block scrolling/selection/caret gestures in editable controls + if (isEditableTarget(e.target)) return; + const s = scrollRegionRef.current; if (!s) { e.preventDefault(); @@ -360,6 +428,9 @@ export default function KaiVohModal({ open, onClose }: KaiVohModalProps) { // Wheel gating (trackpads can “overscroll” background under fixed body in some browsers) const onWheel = (e: WheelEvent): void => { + // ✅ never interfere with wheel/trackpad inside textareas/inputs + if (isEditableTarget(e.target)) return; + const s = scrollRegionRef.current; if (!s) { e.preventDefault(); @@ -453,10 +524,7 @@ export default function KaiVohModal({ open, onClose }: KaiVohModalProps) { ev.preventDefault(); }; document.addEventListener("gesturestart", onGesture, { passive: false, signal: ac.signal } as AddEventListenerOptions); - document.addEventListener("gesturechange", onGesture, { - passive: false, - signal: ac.signal, - } as AddEventListenerOptions); + document.addEventListener("gesturechange", onGesture, { passive: false, signal: ac.signal } as AddEventListenerOptions); document.addEventListener("gestureend", onGesture, { passive: false, signal: ac.signal } as AddEventListenerOptions); const onSubmit = (e: Event): void => { @@ -471,7 +539,14 @@ export default function KaiVohModal({ open, onClose }: KaiVohModalProps) { return () => { // Remove listeners ac.abort(); - window.removeEventListener("resize", syncVh); + + window.removeEventListener("resize", onViewportResize); + vv?.removeEventListener("resize", onViewportResize as EventListener); + vv?.removeEventListener("scroll", onViewportResize as EventListener); + document.removeEventListener("focusin", onFocusIn as EventListener, true); + document.removeEventListener("focusout", onFocusOut as EventListener, true); + + if (rafId) cancelAnimationFrame(rafId); // Restore global styles/vars document.body.style.overflow = prev.bodyOverflow; diff --git a/src/components/KaiVoh/styles/KaiVohModal.css b/src/components/KaiVoh/styles/KaiVohModal.css index b6ce427a..4c778f7e 100644 --- a/src/components/KaiVoh/styles/KaiVohModal.css +++ b/src/components/KaiVoh/styles/KaiVohModal.css @@ -1,15 +1,15 @@ -/* ────────────────────────────────────────────────────────────── +/* 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) - ────────────────────────────────────────────────────────────── */ +*/ /* 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) */ @@ -48,13 +48,7 @@ --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 +60,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 +73,11 @@ 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) */ + /* keep this: backdrop blocks stray gestures; scroll is allowed only in .kai-voh-body */ touch-action: none; - /* Prevent highlight/selection weirdness on iOS */ -webkit-tap-highlight-color: transparent; } @@ -152,19 +143,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 +176,19 @@ isolation:isolate; - /* HARD: block pan/zoom gestures on container (scroll allowed only in inner region) */ - touch-action: none; + /* ✅ KEY FIX: do NOT block touch-action at the container level (inputs live here) */ + 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; } +/* ...everything else below is unchanged from your file... */ +/* (Keep the rest of your CSS exactly as you pasted it) */ + + .glass-omni::before{ content:""; position:absolute; inset:0; pointer-events:none; diff --git a/src/version.ts b/src/version.ts index a2f6abe3..c3ea10e0 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.6"; // Canonical offline/PWA version +export const BASE_APP_VERSION = "42.4.7"; // 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 = From 1d4b9603e673aeef02e9272d2ad62633f9d32166 Mon Sep 17 00:00:00 2001 From: Kojib Date: Fri, 30 Jan 2026 15:06:48 -0500 Subject: [PATCH 13/60] v42.4.8 --- public/sw.js | 2 +- src/App.tsx | 49 +++++++++++- src/entry-client.tsx | 117 +++++++++++++++------------- src/hooks/useDisableZoom.ts | 39 +++++----- src/hooks/useVisualViewportSize.ts | 121 +++++++++++++++++++++++++++-- src/version.ts | 2 +- 6 files changed, 246 insertions(+), 84 deletions(-) diff --git a/public/sw.js b/public/sw.js index 3e15c863..72ff4514 100644 --- a/public/sw.js +++ b/public/sw.js @@ -14,7 +14,7 @@ // Update this version string manually to keep the app + cache versions in sync. // The value is forwarded to the UI via the service worker "SW_ACTIVATED" message. -const APP_VERSION = "42.4.7"; // update on release +const APP_VERSION = "42.4.8"; // update on release const VERSION = new URL(self.location.href).searchParams.get("v") || APP_VERSION; // derived from build const PREFIX = "PHINETWORK"; diff --git a/src/App.tsx b/src/App.tsx index 247fa2e4..417ad6fb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -321,6 +321,19 @@ const DEFAULT_LIVE_SNAP: LiveKaiSnap = { dmyLabel: formatDMYLabel(DEFAULT_BEAT_STEP_DMY), chakraDay: "Heart", }; +function isEditableElement(el: Element | null): boolean { + if (!el) return false; + if (el instanceof HTMLInputElement) return !el.disabled; + if (el instanceof HTMLTextAreaElement) return !el.disabled; + if (el instanceof HTMLSelectElement) return !el.disabled; + if (el instanceof HTMLElement && el.isContentEditable) return true; + return false; +} + +function isEditingNow(): boolean { + if (typeof document === "undefined") return false; + return isEditableElement(document.activeElement as Element | null); +} function computeBeatStepDMY(m: KaiMoment): BeatStepDMY { const pulse = readNum(m, "pulse") ?? 0; @@ -441,8 +454,40 @@ function ExplorerPopover({ children, }: ExplorerPopoverProps): React.JSX.Element | null { const hydrated = useHydrated(); - const vvSizeRaw = useVisualViewportSize(); - const vvSize = hydrated ? vvSizeRaw : { width: 0, height: 0 }; +const vvSizeRaw = useVisualViewportSize(); + +// ✅ Freeze viewport height while typing (prevents iOS keyboard resize thrash) +const [vvStable, setVvStable] = useState<{ width: number; height: number }>({ width: 0, height: 0 }); + +useIsoLayoutEffect(() => { + if (!hydrated) return; + setVvStable({ width: vvSizeRaw.width, height: vvSizeRaw.height }); + // eslint-disable-next-line react-hooks/exhaustive-deps +}, [hydrated]); + +useEffect(() => { + if (!hydrated) return; + if (isEditingNow()) return; // ✅ do not update while typing + setVvStable((prev) => { + if (prev.width === vvSizeRaw.width && prev.height === vvSizeRaw.height) return prev; + return { width: vvSizeRaw.width, height: vvSizeRaw.height }; + }); +}, [hydrated, vvSizeRaw.width, vvSizeRaw.height]); + +useEffect(() => { + if (!hydrated) return; + + const onFocusOut = (): void => { + // when keyboard closes, resync once + setVvStable({ width: vvSizeRaw.width, height: vvSizeRaw.height }); + }; + + document.addEventListener("focusout", onFocusOut, true); + return () => document.removeEventListener("focusout", onFocusOut, true); +}, [hydrated, vvSizeRaw.width, vvSizeRaw.height]); + +const vvSize = hydrated ? vvStable : { width: 0, height: 0 }; + const portalHost = useMemo(() => { if (!hydrated) return null; diff --git a/src/entry-client.tsx b/src/entry-client.tsx index 5afa8fa3..3974392a 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 8a93d5c1..185a5874 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 a8c5d77f..3311c8a4 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/version.ts b/src/version.ts index c3ea10e0..33877c8e 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.7"; // Canonical offline/PWA version +export const BASE_APP_VERSION = "42.4.8"; // 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 = From 990b4185d4eeb1369422d31cf2cf0ab438b3c095 Mon Sep 17 00:00:00 2001 From: Kojib <123880127+kojibai@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:15:02 -0500 Subject: [PATCH 14/60] Route sigils API through same-origin proxy --- server.mjs | 85 ++++++++++++++++++++++- src/components/SigilExplorer/apiClient.ts | 9 +++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/server.mjs b/server.mjs index 4333c44f..f40c8aaf 100644 --- a/server.mjs +++ b/server.mjs @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import fsSync from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { PassThrough } from "node:stream"; +import { PassThrough, Readable } from "node:stream"; import { createServer as createHttpServer } from "node:http"; import { createServer as createViteServer } from "vite"; import { createHash } from "node:crypto"; @@ -32,6 +32,9 @@ const OG_PATH_PREFIX = "/og/v/verified/"; const OG_CACHE_CONTROL = "public, max-age=0, s-maxage=31536000, immutable"; const OG_CACHE_TTL_MS = 10 * 60 * 1000; const OG_CACHE_MAX_ENTRIES = 512; +const SIGILS_PROXY_PATH = "/sigils"; +const SIGILS_PRIMARY_BASE = "https://m.kai.ac"; +const SIGILS_BACKUP_BASE = "https://memory.kaiklok.com"; const escapeHtml = (value) => String(value) @@ -79,6 +82,84 @@ const tryServeStatic = (req, res, rootDir) => { return true; }; +const shouldFailoverStatus = (status) => { + if (status === 0) return true; + if (status === 404) return true; + if (status === 408 || status === 429) return true; + if (status >= 500) return true; + return false; +}; + +const readRequestBody = async (req) => { + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + } + return chunks.length ? Buffer.concat(chunks) : undefined; +}; + +const buildProxyHeaders = (req) => { + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + const lower = key.toLowerCase(); + if (lower === "host" || lower === "connection" || lower === "content-length") continue; + if (typeof value === "undefined") continue; + if (Array.isArray(value)) { + for (const entry of value) headers.append(lower, entry); + } else { + headers.set(lower, value); + } + } + return headers; +}; + +const proxySigils = async (req, res) => { + if (!req.url?.startsWith(SIGILS_PROXY_PATH)) return false; + + const body = + req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS" + ? undefined + : await readRequestBody(req); + const headers = buildProxyHeaders(req); + const bases = [SIGILS_PRIMARY_BASE, SIGILS_BACKUP_BASE]; + let lastStatus = null; + + for (const base of bases) { + try { + const proxyRes = await fetch(`${base}${req.url}`, { + method: req.method, + headers, + body, + }); + + if (shouldFailoverStatus(proxyRes.status) && base === SIGILS_PRIMARY_BASE) { + lastStatus = proxyRes.status; + continue; + } + + res.statusCode = proxyRes.status; + for (const [key, value] of proxyRes.headers) { + if (key.toLowerCase() === "content-encoding") continue; + res.setHeader(key, value); + } + + if (proxyRes.body) { + Readable.fromWeb(proxyRes.body).pipe(res); + return true; + } + + res.end(); + return true; + } catch { + continue; + } + } + + res.statusCode = lastStatus ?? 502; + res.end("Bad Gateway"); + return true; +}; + async function createServer() { let vite; if (!isProd) { @@ -243,6 +324,8 @@ async function createServer() { return; } + if (await proxySigils(req, res)) return; + if (await handleOgRoute(req, res)) return; if (isProd) { diff --git a/src/components/SigilExplorer/apiClient.ts b/src/components/SigilExplorer/apiClient.ts index 6196962d..8cf6b413 100644 --- a/src/components/SigilExplorer/apiClient.ts +++ b/src/components/SigilExplorer/apiClient.ts @@ -152,6 +152,15 @@ function apiBases(): string[] { return [DEV_API_BASE]; } + if (hasWindow) { + const pageOrigin = window.location.origin; + // In production on non-API domains (e.g. phi.network), stay same-origin to avoid CORS. + // Server-side proxy handles forwarding to the API hosts. + if (pageOrigin !== LIVE_BASE_URL && pageOrigin !== LIVE_BACKUP_URL) { + return [DEV_API_BASE]; + } + } + const wantFallbackFirst = apiBaseHint === API_BASE_FALLBACK && !isBackupSuppressed(); const list = wantFallbackFirst ? [API_BASE_FALLBACK, API_BASE_PRIMARY] From b2ac2c21d349419c7337e74fb61a364302290717 Mon Sep 17 00:00:00 2001 From: Kojib <123880127+kojibai@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:25:30 -0500 Subject: [PATCH 15/60] Fix sigil API fallback on non-API domains --- src/components/SigilExplorer/apiClient.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/SigilExplorer/apiClient.ts b/src/components/SigilExplorer/apiClient.ts index 8cf6b413..1fb0932b 100644 --- a/src/components/SigilExplorer/apiClient.ts +++ b/src/components/SigilExplorer/apiClient.ts @@ -154,10 +154,11 @@ function apiBases(): string[] { if (hasWindow) { const pageOrigin = window.location.origin; - // In production on non-API domains (e.g. phi.network), stay same-origin to avoid CORS. - // Server-side proxy handles forwarding to the API hosts. + // In production on non-API domains (e.g. phi.network), try same-origin first + // (if a proxy exists), then fall back to the live API hosts. if (pageOrigin !== LIVE_BASE_URL && pageOrigin !== LIVE_BACKUP_URL) { - return [DEV_API_BASE]; + const list = [DEV_API_BASE, LIVE_BASE_URL, LIVE_BACKUP_URL]; + return isBackupSuppressed() ? list.filter((b) => b !== LIVE_BACKUP_URL) : list; } } From 766239efd9ad477247a4a88b9c34e62dae0f5f14 Mon Sep 17 00:00:00 2001 From: Kojib <123880127+kojibai@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:33:50 -0500 Subject: [PATCH 16/60] Restore production API base selection --- src/components/SigilExplorer/apiClient.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/components/SigilExplorer/apiClient.ts b/src/components/SigilExplorer/apiClient.ts index 1fb0932b..6196962d 100644 --- a/src/components/SigilExplorer/apiClient.ts +++ b/src/components/SigilExplorer/apiClient.ts @@ -152,16 +152,6 @@ function apiBases(): string[] { return [DEV_API_BASE]; } - if (hasWindow) { - const pageOrigin = window.location.origin; - // In production on non-API domains (e.g. phi.network), try same-origin first - // (if a proxy exists), then fall back to the live API hosts. - if (pageOrigin !== LIVE_BASE_URL && pageOrigin !== LIVE_BACKUP_URL) { - const list = [DEV_API_BASE, LIVE_BASE_URL, LIVE_BACKUP_URL]; - return isBackupSuppressed() ? list.filter((b) => b !== LIVE_BACKUP_URL) : list; - } - } - const wantFallbackFirst = apiBaseHint === API_BASE_FALLBACK && !isBackupSuppressed(); const list = wantFallbackFirst ? [API_BASE_FALLBACK, API_BASE_PRIMARY] From b419cdc6cb1945cd734444fbbec888aa4610e4a5 Mon Sep 17 00:00:00 2001 From: Kojib <123880127+kojibai@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:43:25 -0500 Subject: [PATCH 17/60] Fix API base selection to prefer same-origin proxy --- src/components/SigilExplorer/apiClient.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/components/SigilExplorer/apiClient.ts b/src/components/SigilExplorer/apiClient.ts index 6196962d..dca626f1 100644 --- a/src/components/SigilExplorer/apiClient.ts +++ b/src/components/SigilExplorer/apiClient.ts @@ -30,6 +30,7 @@ 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) /** * Dev API base: @@ -45,6 +46,13 @@ 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 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 +181,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 { From 376a1350ce3b192abe1013dd2ab29f3e3e191d5f Mon Sep 17 00:00:00 2001 From: Kojib Date: Fri, 30 Jan 2026 15:45:35 -0500 Subject: [PATCH 18/60] v42.4.9 --- public/sw.js | 2 +- src/version.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/sw.js b/public/sw.js index 72ff4514..cd7079e9 100644 --- a/public/sw.js +++ b/public/sw.js @@ -14,7 +14,7 @@ // Update this version string manually to keep the app + cache versions in sync. // The value is forwarded to the UI via the service worker "SW_ACTIVATED" message. -const APP_VERSION = "42.4.8"; // update on release +const APP_VERSION = "42.4.9"; // update on release const VERSION = new URL(self.location.href).searchParams.get("v") || APP_VERSION; // derived from build const PREFIX = "PHINETWORK"; diff --git a/src/version.ts b/src/version.ts index 33877c8e..26e1ab58 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.8"; // Canonical offline/PWA version +export const BASE_APP_VERSION = "42.4.9"; // 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 = From c7890b16e6ea3410a65ce282392b360d304069fb Mon Sep 17 00:00:00 2001 From: Kojib Date: Fri, 30 Jan 2026 16:07:58 -0500 Subject: [PATCH 19/60] v42.5.0 --- public/sw.js | 2 +- src/App.tsx | 191 ++++++++++++++++++++++++++++++++++++++++--------- src/version.ts | 2 +- 3 files changed, 160 insertions(+), 35 deletions(-) diff --git a/public/sw.js b/public/sw.js index cd7079e9..785190d1 100644 --- a/public/sw.js +++ b/public/sw.js @@ -14,7 +14,7 @@ // Update this version string manually to keep the app + cache versions in sync. // The value is forwarded to the UI via the service worker "SW_ACTIVATED" message. -const APP_VERSION = "42.4.9"; // update on release +const APP_VERSION = "42.5.0"; // update on release const VERSION = new URL(self.location.href).searchParams.get("v") || APP_VERSION; // derived from build const PREFIX = "PHINETWORK"; diff --git a/src/App.tsx b/src/App.tsx index 417ad6fb..c737af9c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -230,6 +230,12 @@ type VerifyPopoverProps = { children: React.ReactNode; }; +type KaiVohPopoverProps = { + open: boolean; + onClose: () => void; + children: React.ReactNode; +}; + type KlockPopoverProps = { open: boolean; onClose: () => void; @@ -321,6 +327,7 @@ const DEFAULT_LIVE_SNAP: LiveKaiSnap = { dmyLabel: formatDMYLabel(DEFAULT_BEAT_STEP_DMY), chakraDay: "Heart", }; + function isEditableElement(el: Element | null): boolean { if (!el) return false; if (el instanceof HTMLInputElement) return !el.disabled; @@ -454,40 +461,39 @@ function ExplorerPopover({ children, }: ExplorerPopoverProps): React.JSX.Element | null { const hydrated = useHydrated(); -const vvSizeRaw = useVisualViewportSize(); - -// ✅ Freeze viewport height while typing (prevents iOS keyboard resize thrash) -const [vvStable, setVvStable] = useState<{ width: number; height: number }>({ width: 0, height: 0 }); - -useIsoLayoutEffect(() => { - if (!hydrated) return; - setVvStable({ width: vvSizeRaw.width, height: vvSizeRaw.height }); - // eslint-disable-next-line react-hooks/exhaustive-deps -}, [hydrated]); - -useEffect(() => { - if (!hydrated) return; - if (isEditingNow()) return; // ✅ do not update while typing - setVvStable((prev) => { - if (prev.width === vvSizeRaw.width && prev.height === vvSizeRaw.height) return prev; - return { width: vvSizeRaw.width, height: vvSizeRaw.height }; - }); -}, [hydrated, vvSizeRaw.width, vvSizeRaw.height]); - -useEffect(() => { - if (!hydrated) return; - - const onFocusOut = (): void => { - // when keyboard closes, resync once + const vvSizeRaw = useVisualViewportSize(); + + // ✅ Freeze viewport height while typing (prevents iOS keyboard resize thrash) + const [vvStable, setVvStable] = useState<{ width: number; height: number }>({ width: 0, height: 0 }); + + useIsoLayoutEffect(() => { + if (!hydrated) return; setVvStable({ width: vvSizeRaw.width, height: vvSizeRaw.height }); - }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hydrated]); - document.addEventListener("focusout", onFocusOut, true); - return () => document.removeEventListener("focusout", onFocusOut, true); -}, [hydrated, vvSizeRaw.width, vvSizeRaw.height]); + useEffect(() => { + if (!hydrated) return; + if (isEditingNow()) return; // ✅ do not update while typing + setVvStable((prev) => { + if (prev.width === vvSizeRaw.width && prev.height === vvSizeRaw.height) return prev; + return { width: vvSizeRaw.width, height: vvSizeRaw.height }; + }); + }, [hydrated, vvSizeRaw.width, vvSizeRaw.height]); -const vvSize = hydrated ? vvStable : { width: 0, height: 0 }; + useEffect(() => { + if (!hydrated) return; + + const onFocusOut = (): void => { + // when keyboard closes, resync once + setVvStable({ width: vvSizeRaw.width, height: vvSizeRaw.height }); + }; + + document.addEventListener("focusout", onFocusOut, true); + return () => document.removeEventListener("focusout", onFocusOut, true); + }, [hydrated, vvSizeRaw.width, vvSizeRaw.height]); + const vvSize = hydrated ? vvStable : { width: 0, height: 0 }; const portalHost = useMemo(() => { if (!hydrated) return null; @@ -700,6 +706,122 @@ function VerifyPopover({ open, onClose, children }: VerifyPopoverProps): React.J ); } +/* ────────────────────────────────────────────────────────────────────────────── + KaiVoh Popover — EXACT SAME POP STYLE AS ATTESTATION +────────────────────────────────────────────────────────────────────────────── */ +function KaiVohPopover({ open, onClose, children }: KaiVohPopoverProps): React.JSX.Element | null { + const hydrated = useHydrated(); + const vvSizeRaw = useVisualViewportSize(); + const vvSize = hydrated ? vvSizeRaw : { width: 0, height: 0 }; + + const portalHost = useMemo(() => { + if (!hydrated) return null; + return getPortalHost(); + }, [hydrated]); + + useBodyScrollLock(open && hydrated); + + useEffect(() => { + if (!open || !hydrated) return; + + const onKey = (e: KeyboardEvent): void => { + if (e.key === "Escape") onClose(); + }; + + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, onClose, hydrated]); + + const closeBtnRef = useRef(null); + useEffect(() => { + if (!open || !hydrated) return; + window.requestAnimationFrame(() => closeBtnRef.current?.focus()); + }, [open, hydrated]); + + const overlayStyle = useMemo(() => { + if (!open || !hydrated) return undefined; + + const h = vvSize.height; + const w = vvSize.width; + + return { + position: "fixed", + inset: 0, + pointerEvents: "auto", + height: h > 0 ? `${h}px` : undefined, + width: w > 0 ? `${w}px` : undefined, + + ["--sx-breath"]: "5.236s", + ["--sx-border"]: "rgba(60, 220, 205, 0.35)", + ["--sx-border-strong"]: "rgba(55, 255, 228, 0.55)", + ["--sx-ring"]: + "0 0 0 2px rgba(55, 255, 228, 0.25), 0 0 0 6px rgba(55, 255, 228, 0.12)", + }; + }, [open, hydrated, vvSize.height, vvSize.width]); + + const onBackdropPointerDown = useCallback((e: React.PointerEvent): void => { + if (e.target === e.currentTarget) { + e.preventDefault(); + e.stopPropagation(); + } + }, []); + + const onBackdropClick = useCallback( + (e: React.MouseEvent): void => { + if (e.target === e.currentTarget) { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } + }, + [onClose], + ); + + const onClosePointerDown = useCallback((e: React.PointerEvent): void => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + if (!open || !hydrated || !portalHost) return null; + + return createPortal( +
+
+ + +
{children}
+ +
+ KaiVoh portal open +
+
+
, + portalHost, + ); +} + function ExplorerFallback(): React.JSX.Element { return (
- - - + + + + + +
KaiVoh portal open
diff --git a/src/version.ts b/src/version.ts index 26e1ab58..e05b7132 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.9"; // Canonical offline/PWA version +export const BASE_APP_VERSION = "42.5.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 = From c914170ebb8cc7343a24d93d83f5cf55d254d37f Mon Sep 17 00:00:00 2001 From: Kojib <123880127+kojibai@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:14:15 -0500 Subject: [PATCH 20/60] Fix SigilExplorer API URL construction --- src/components/SigilExplorer.tsx | 39 +++++++++++++------ .../SigilExplorer/SigilExplorer.tsx | 15 ++++--- .../SigilExplorer/SigilHoneycombExplorer.tsx | 2 +- src/components/SigilExplorer/inhaleQueue.ts | 11 +++++- 4 files changed, 47 insertions(+), 20 deletions(-) diff --git a/src/components/SigilExplorer.tsx b/src/components/SigilExplorer.tsx index 1c874e9a..091c606b 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 430f69b9..eee703c8 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 5d16eada..d110ee9c 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/inhaleQueue.ts b/src/components/SigilExplorer/inhaleQueue.ts index 9ce2a47a..3c826873 100644 --- a/src/components/SigilExplorer/inhaleQueue.ts +++ b/src/components/SigilExplorer/inhaleQueue.ts @@ -232,10 +232,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 }); From ae25e66119498fd5fe2270a83669bed5d6494d51 Mon Sep 17 00:00:00 2001 From: Kojib <123880127+kojibai@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:22:01 -0500 Subject: [PATCH 21/60] Gate same-origin SigilExplorer proxy to dev or opt-in --- src/components/SigilExplorer/apiClient.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/SigilExplorer/apiClient.ts b/src/components/SigilExplorer/apiClient.ts index dca626f1..bd80ad15 100644 --- a/src/components/SigilExplorer/apiClient.ts +++ b/src/components/SigilExplorer/apiClient.ts @@ -31,6 +31,7 @@ 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: @@ -48,7 +49,9 @@ function isLocalDevOrigin(origin: string): boolean { function shouldTrySameOriginProxy(origin: string): boolean { if (!origin || origin === "null") return false; - if (isLocalDevOrigin(origin)) 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; } From bd2496dc70a52273990838fbfc94b666fb2f962b Mon Sep 17 00:00:00 2001 From: Kojib <123880127+kojibai@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:42:17 -0500 Subject: [PATCH 22/60] Limit inhale batch payload size --- src/components/SigilExplorer/inhaleQueue.ts | 23 ++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/components/SigilExplorer/inhaleQueue.ts b/src/components/SigilExplorer/inhaleQueue.ts index 3c826873..13b73a9c 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,31 @@ async function flushInhaleQueue(): Promise { try { const batch: Record[] = []; const keys: string[] = []; + const encoder = typeof TextEncoder !== "undefined" ? new TextEncoder() : null; + let currentBytes = 2; 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 && + (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; + } } const json = JSON.stringify(batch); From 4cff8a4b54501c5420e1c9aeaf1c72f3bd14752c Mon Sep 17 00:00:00 2001 From: Kojib Date: Fri, 30 Jan 2026 16:43:47 -0500 Subject: [PATCH 23/60] v42.5.1 --- public/sw.js | 2 +- src/version.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/sw.js b/public/sw.js index 785190d1..455249a5 100644 --- a/public/sw.js +++ b/public/sw.js @@ -14,7 +14,7 @@ // Update this version string manually to keep the app + cache versions in sync. // The value is forwarded to the UI via the service worker "SW_ACTIVATED" message. -const APP_VERSION = "42.5.0"; // update on release +const APP_VERSION = "42.5.1"; // update on release const VERSION = new URL(self.location.href).searchParams.get("v") || APP_VERSION; // derived from build const PREFIX = "PHINETWORK"; diff --git a/src/version.ts b/src/version.ts index e05b7132..77704cf5 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.5.0"; // Canonical offline/PWA version +export const BASE_APP_VERSION = "42.5.1"; // 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 = From ef058d300337570a8104b87ea63d1d5cb660d61c Mon Sep 17 00:00:00 2001 From: Kojib Date: Fri, 30 Jan 2026 17:25:52 -0500 Subject: [PATCH 24/60] v42.5.2 --- public/sw.js | 2 +- src/App.tsx | 191 +----- src/components/KaiVoh/KaiVoh.tsx | 444 +++++++------ src/components/KaiVoh/KaiVohApp.tsx | 297 +++++---- src/components/KaiVoh/KaiVohModal.tsx | 210 ++----- src/components/KaiVoh/styles/KaiVoh.css | 171 +++-- src/components/KaiVoh/styles/KaiVohApp.css | 618 +++++-------------- src/components/KaiVoh/styles/KaiVohModal.css | 248 ++------ src/version.ts | 2 +- 9 files changed, 718 insertions(+), 1465 deletions(-) diff --git a/public/sw.js b/public/sw.js index 455249a5..c06c5348 100644 --- a/public/sw.js +++ b/public/sw.js @@ -14,7 +14,7 @@ // Update this version string manually to keep the app + cache versions in sync. // The value is forwarded to the UI via the service worker "SW_ACTIVATED" message. -const APP_VERSION = "42.5.1"; // update on release +const APP_VERSION = "42.5.2"; // update on release const VERSION = new URL(self.location.href).searchParams.get("v") || APP_VERSION; // derived from build const PREFIX = "PHINETWORK"; diff --git a/src/App.tsx b/src/App.tsx index c737af9c..417ad6fb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -230,12 +230,6 @@ type VerifyPopoverProps = { children: React.ReactNode; }; -type KaiVohPopoverProps = { - open: boolean; - onClose: () => void; - children: React.ReactNode; -}; - type KlockPopoverProps = { open: boolean; onClose: () => void; @@ -327,7 +321,6 @@ const DEFAULT_LIVE_SNAP: LiveKaiSnap = { dmyLabel: formatDMYLabel(DEFAULT_BEAT_STEP_DMY), chakraDay: "Heart", }; - function isEditableElement(el: Element | null): boolean { if (!el) return false; if (el instanceof HTMLInputElement) return !el.disabled; @@ -461,39 +454,40 @@ function ExplorerPopover({ children, }: ExplorerPopoverProps): React.JSX.Element | null { const hydrated = useHydrated(); - const vvSizeRaw = useVisualViewportSize(); - - // ✅ Freeze viewport height while typing (prevents iOS keyboard resize thrash) - const [vvStable, setVvStable] = useState<{ width: number; height: number }>({ width: 0, height: 0 }); - - useIsoLayoutEffect(() => { - if (!hydrated) return; +const vvSizeRaw = useVisualViewportSize(); + +// ✅ Freeze viewport height while typing (prevents iOS keyboard resize thrash) +const [vvStable, setVvStable] = useState<{ width: number; height: number }>({ width: 0, height: 0 }); + +useIsoLayoutEffect(() => { + if (!hydrated) return; + setVvStable({ width: vvSizeRaw.width, height: vvSizeRaw.height }); + // eslint-disable-next-line react-hooks/exhaustive-deps +}, [hydrated]); + +useEffect(() => { + if (!hydrated) return; + if (isEditingNow()) return; // ✅ do not update while typing + setVvStable((prev) => { + if (prev.width === vvSizeRaw.width && prev.height === vvSizeRaw.height) return prev; + return { width: vvSizeRaw.width, height: vvSizeRaw.height }; + }); +}, [hydrated, vvSizeRaw.width, vvSizeRaw.height]); + +useEffect(() => { + if (!hydrated) return; + + const onFocusOut = (): void => { + // when keyboard closes, resync once setVvStable({ width: vvSizeRaw.width, height: vvSizeRaw.height }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hydrated]); - - useEffect(() => { - if (!hydrated) return; - if (isEditingNow()) return; // ✅ do not update while typing - setVvStable((prev) => { - if (prev.width === vvSizeRaw.width && prev.height === vvSizeRaw.height) return prev; - return { width: vvSizeRaw.width, height: vvSizeRaw.height }; - }); - }, [hydrated, vvSizeRaw.width, vvSizeRaw.height]); - - useEffect(() => { - if (!hydrated) return; + }; - const onFocusOut = (): void => { - // when keyboard closes, resync once - setVvStable({ width: vvSizeRaw.width, height: vvSizeRaw.height }); - }; + document.addEventListener("focusout", onFocusOut, true); + return () => document.removeEventListener("focusout", onFocusOut, true); +}, [hydrated, vvSizeRaw.width, vvSizeRaw.height]); - document.addEventListener("focusout", onFocusOut, true); - return () => document.removeEventListener("focusout", onFocusOut, true); - }, [hydrated, vvSizeRaw.width, vvSizeRaw.height]); +const vvSize = hydrated ? vvStable : { width: 0, height: 0 }; - const vvSize = hydrated ? vvStable : { width: 0, height: 0 }; const portalHost = useMemo(() => { if (!hydrated) return null; @@ -706,122 +700,6 @@ function VerifyPopover({ open, onClose, children }: VerifyPopoverProps): React.J ); } -/* ────────────────────────────────────────────────────────────────────────────── - KaiVoh Popover — EXACT SAME POP STYLE AS ATTESTATION -────────────────────────────────────────────────────────────────────────────── */ -function KaiVohPopover({ open, onClose, children }: KaiVohPopoverProps): React.JSX.Element | null { - const hydrated = useHydrated(); - const vvSizeRaw = useVisualViewportSize(); - const vvSize = hydrated ? vvSizeRaw : { width: 0, height: 0 }; - - const portalHost = useMemo(() => { - if (!hydrated) return null; - return getPortalHost(); - }, [hydrated]); - - useBodyScrollLock(open && hydrated); - - useEffect(() => { - if (!open || !hydrated) return; - - const onKey = (e: KeyboardEvent): void => { - if (e.key === "Escape") onClose(); - }; - - document.addEventListener("keydown", onKey); - return () => document.removeEventListener("keydown", onKey); - }, [open, onClose, hydrated]); - - const closeBtnRef = useRef(null); - useEffect(() => { - if (!open || !hydrated) return; - window.requestAnimationFrame(() => closeBtnRef.current?.focus()); - }, [open, hydrated]); - - const overlayStyle = useMemo(() => { - if (!open || !hydrated) return undefined; - - const h = vvSize.height; - const w = vvSize.width; - - return { - position: "fixed", - inset: 0, - pointerEvents: "auto", - height: h > 0 ? `${h}px` : undefined, - width: w > 0 ? `${w}px` : undefined, - - ["--sx-breath"]: "5.236s", - ["--sx-border"]: "rgba(60, 220, 205, 0.35)", - ["--sx-border-strong"]: "rgba(55, 255, 228, 0.55)", - ["--sx-ring"]: - "0 0 0 2px rgba(55, 255, 228, 0.25), 0 0 0 6px rgba(55, 255, 228, 0.12)", - }; - }, [open, hydrated, vvSize.height, vvSize.width]); - - const onBackdropPointerDown = useCallback((e: React.PointerEvent): void => { - if (e.target === e.currentTarget) { - e.preventDefault(); - e.stopPropagation(); - } - }, []); - - const onBackdropClick = useCallback( - (e: React.MouseEvent): void => { - if (e.target === e.currentTarget) { - e.preventDefault(); - e.stopPropagation(); - onClose(); - } - }, - [onClose], - ); - - const onClosePointerDown = useCallback((e: React.PointerEvent): void => { - e.preventDefault(); - e.stopPropagation(); - }, []); - - if (!open || !hydrated || !portalHost) return null; - - return createPortal( -
-
- - -
{children}
- -
- KaiVoh portal open -
-
-
, - portalHost, - ); -} - function ExplorerFallback(): React.JSX.Element { return (
- - - - - - + + +
KaiVoh portal open
diff --git a/src/components/KaiVoh/KaiVoh.tsx b/src/components/KaiVoh/KaiVoh.tsx index 575236b0..c535d984 100644 --- a/src/components/KaiVoh/KaiVoh.tsx +++ b/src/components/KaiVoh/KaiVoh.tsx @@ -4,18 +4,11 @@ /** * KaiVoh — Stream Exhale Composer * v5.0 — PRIVATE SEALING (real encryption, not “pulse lock”) - * - 🔒 Optional “Private (Sealed)” mode: encrypts inner content BEFORE token encode - * - Two access paths (choose one): - * A) DERIVED GLYPH ACCESS: any derivative glyph exported from the issuer’s verifier unlocks - * B) SPECIFIC GLYPH ACCESS: only uploaded/allowed glyph(s) can unlock (pulse-agnostic) - * - Hard guard: private posts may NOT contain cache-only file-ref attachments (must be inline or URL) - * - Keeps SAME token format (encodeTokenWithBudgets from feedPayload) - * - Worker-first encode with deterministic main-thread fallback (iOS/Safari-safe) * - * Primary role: - * - Exhale a /stream/p/ URL bound to the current verified Sigil. - * - Attach documents, folders, tiny inline files, extra URLs, and recorded stories. - * - Embed parentUrl/originUrl lineage + register the stream URL with Sigil Explorer. + * FIX (v5.0.1 - no-jank typing / no "reload"): + * - Caption/Author/Teaser/URL inputs are UNCONTROLLED (refs) → typing does NOT re-render KaiVoh. + * - Draft saving is debounced and reads from refs + current state snapshot. + * - Post body + derived caption computed ONLY on Exhale (not per-keystroke). */ import { useEffect, useMemo, useRef, useState } from "react"; @@ -49,7 +42,7 @@ import StoryRecorder, { type CapturedStory } from "./StoryRecorder"; import { registerSigilUrl } from "../../utils/sigilRegistry"; import { getOriginUrl } from "../../utils/sigilUrl"; -/* 🔒 Sealing utilities (new) */ +/* 🔒 Sealing utilities */ import { sealEnvelopeV1, makeSealSaltB64Url, type GlyphCredential, type SealedEnvelopeV1 } from "../../utils/postSeal"; import { extractSigilAuthFromSvg } from "../../utils/sigilAuthExtract"; import { deriveKaiSignatureB64Url } from "../../utils/derivedGlyph"; @@ -68,7 +61,7 @@ export interface KaiVohProps { onExhale?: (result: KaiVohExhaleResult) => void; } -/* ───────────────────────── Inline Icons (no visible text) ───────────────────────── */ +/* ───────────────────────── Inline Icons ───────────────────────── */ function IconCamRecord(): ReactElement { return ( @@ -96,7 +89,9 @@ const MAX_INLINE_BYTES = 6_000 as const; // per-file inline cap const KB = 1024; const MB = 1024 * KB; -/* ───────────────────────── Small utils (no any) ───────────────────────── */ +const DRAFT_STORAGE_KEY = "kai-voh:draft:v1"; + +/* ───────────────────────── Small utils ───────────────────────── */ const prettyBytes = (n: number): string => { if (n >= MB) return `${(n / MB).toFixed(2)} MB`; @@ -107,6 +102,16 @@ const prettyBytes = (n: number): string => { const short = (s: string, head = 8, tail = 6): string => s.length <= head + tail ? s : `${s.slice(0, head)}…${s.slice(-tail)}`; +const trunc = (s: string, max: number): string => { + if (s.length <= max) return s; + return `${s.slice(0, Math.max(0, max - 1))}…`; +}; + +function firstLine(s: string): string { + const n = s.indexOf("\n"); + return n >= 0 ? s.slice(0, n) : s; +} + const isHttpUrl = (s: unknown): s is string => { if (typeof s !== "string" || !s) return false; try { @@ -117,7 +122,6 @@ const isHttpUrl = (s: unknown): s is string => { } }; -/** Any supported stream link form? (#t=, ?p=, /stream|feed/p/, /p~) */ function isLikelySigilUrl(u: string): boolean { try { const url = new URL(u, globalThis.location?.origin ?? "https://example.org"); @@ -134,7 +138,6 @@ function isLikelySigilUrl(u: string): boolean { /** * base64url (byte-safe, no btoa/atob) - * Prevents “giant string” stalls and works for any ArrayBuffer size. */ function base64UrlEncodeBytes(buf: ArrayBuffer): string { const bytes = new Uint8Array(buf); @@ -149,7 +152,7 @@ function base64UrlEncodeBytes(buf: ArrayBuffer): string { alphabet[(x >>> 18) & 63] + alphabet[(x >>> 12) & 63] + alphabet[(x >>> 6) & 63] + - alphabet[x & 63], + alphabet[x & 63] ); } @@ -165,7 +168,6 @@ function base64UrlEncodeBytes(buf: ArrayBuffer): string { return outParts.join("").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); } -/** Read string/number from object or nested meta, safely */ function readStringProp(obj: unknown, key: string): string | undefined { if (typeof obj !== "object" || obj === null) return undefined; const r = obj as Record; @@ -192,10 +194,9 @@ function readNumberProp(obj: unknown, key: string): number | undefined { return undefined; } -/** Extract action URL from SVG text (metadata JSON, CDATA, or href) */ function extractSigilActionUrlFromSvgText( svgText?: string | null, - metaCandidate?: Record, + metaCandidate?: Record ): string | undefined { if (!svgText) return undefined; @@ -250,11 +251,10 @@ function extractSigilActionUrlFromSvgText( return undefined; } -/** Cache helper: store blob under /att/ and return the URL */ async function cachePutAndUrl( sha256: string, blob: Blob, - opts: { cacheName?: string; pathPrefix?: string } = {}, + opts: { cacheName?: string; pathPrefix?: string } = {} ): Promise { const cacheName = opts.cacheName ?? "sigil-attachments-v1"; const pathPrefix = (opts.pathPrefix ?? "/att/").replace(/\/+$/, "") + "/"; @@ -265,7 +265,7 @@ async function cachePutAndUrl( const url = `${pathPrefix}${sha256}`; await cache.put( new Request(url, { method: "GET" }), - new Response(blob, { headers: { "Content-Type": blob.type || "application/octet-stream" } }), + new Response(blob, { headers: { "Content-Type": blob.type || "application/octet-stream" } }) ); return url; } catch { @@ -275,22 +275,12 @@ async function cachePutAndUrl( function formatMs(ms: number): string { const s = Math.floor(ms / 1000); - const mm = Math.floor(s / 60) - .toString() - .padStart(2, "0"); + const mm = Math.floor(s / 60).toString().padStart(2, "0"); const ss = (s % 60).toString().padStart(2, "0"); return `${mm}:${ss}`; } -function firstLine(s: string): string { - const n = s.indexOf("\n"); - return n >= 0 ? s.slice(0, n) : s; -} - -function trunc(s: string, max: number): string { - if (s.length <= max) return s; - return `${s.slice(0, Math.max(0, max - 1))}…`; -} +/* ───────────────────────── Types ───────────────────────── */ type BodyKind = "text" | "code" | "md" | "html"; type HtmlMode = "code" | "sanitized"; @@ -299,7 +289,7 @@ type UrlItem = Extract; type SealMode = "derived" | "glyph"; type AllowedGlyph = GlyphCredential & { - label: string; // file name or user label + label: string; }; type DraftState = { @@ -319,7 +309,6 @@ type DraftState = { sealAdvanced: boolean; }; -const DRAFT_STORAGE_KEY = "kai-voh:draft:v1"; const BODY_KIND_OPTIONS: BodyKind[] = ["text", "code", "md", "html"]; const HTML_MODE_OPTIONS: HtmlMode[] = ["code", "sanitized"]; const SEAL_MODE_OPTIONS: SealMode[] = ["derived", "glyph"]; @@ -351,10 +340,10 @@ const parsePulseLoose = (raw: unknown): number | null => { }; /** - * ✅ Pulse mirror: - * - Subscribes to aligned ticker (same source as KaiStatus) + * Pulse mirror: + * - Subscribes to aligned ticker * - Writes latest pulse into a ref - * - KaiVoh does NOT re-render on each pulse (only this child does) + * - KaiVoh does NOT re-render on each pulse */ function KaiVohPulseMirror({ pulseRef }: { pulseRef: MutableRefObject }): null { const kaiNow = useAlignedKaiTicker() as unknown; @@ -391,7 +380,7 @@ const readDraftAllowedGlyphs = (v: unknown): AllowedGlyph[] => { return out; }; -/* ───────────────────────── Non-hanging encode (REAL Module Worker file) ───────────────────────── */ +/* ───────────────────────── Non-hanging encode (worker-first) ───────────────────────── */ type EncodeWorkerRequest = { id: string; payload: FeedPostPayload }; @@ -435,7 +424,6 @@ const makeId = (): string => { if (c && "randomUUID" in c && typeof c.randomUUID === "function") return c.randomUUID(); - // Chronos-free fallback: 16 random bytes → hex if (c && "getRandomValues" in c && typeof c.getRandomValues === "function") { const bytes = new Uint8Array(16); c.getRandomValues(bytes); @@ -444,11 +432,9 @@ const makeId = (): string => { return out; } - // Absolute last resort (should never happen in modern browsers) return `id-${Math.random().toString(16).slice(2)}-${Math.random().toString(16).slice(2)}`; }; -// Singleton worker plumbing (module-scope; not React state) let _encodeWorker: Worker | null = null; const _pending = new Map void>(); @@ -457,7 +443,6 @@ function getEncodeWorker(): Worker { if (typeof window === "undefined") throw new Error("encode worker unavailable (no window)"); if (typeof Worker === "undefined") throw new Error("encode worker unavailable (Worker not supported)"); - // ✅ Real worker module file (bundler-safe; no blob; no import() inside worker) const url = new URL("./encodeToken.worker.ts", import.meta.url); _encodeWorker = new Worker(url, { type: "module", name: "kaiVohEncodeWorker" }); @@ -470,7 +455,6 @@ function getEncodeWorker(): Worker { }; _encodeWorker.onerror = () => { - // Fail all inflight and reset. Keep ids stable. for (const [id, cb] of _pending) cb({ id, ok: false, error: "encode worker crashed", ms: 0 }); _pending.clear(); try { @@ -495,43 +479,26 @@ async function encodeTokenInWorker(payload: FeedPostPayload): Promise { const t0 = nowMs(); const mainThread = (): EncodeWorkerResponse => { try { const out = encodeTokenWithBudgets(payload); - return { - id: makeId(), - ok: true, - token: out.token, - withinHard: out.withinHard, - ms: nowMs() - t0, - }; + return { id: makeId(), ok: true, token: out.token, withinHard: out.withinHard, ms: nowMs() - t0 }; } catch (err) { - return { - id: makeId(), - ok: false, - error: err instanceof Error ? err.message : String(err), - ms: nowMs() - t0, - }; + return { id: makeId(), ok: false, error: err instanceof Error ? err.message : String(err), ms: nowMs() - t0 }; } }; try { const res = await encodeTokenInWorker(payload); - - // ✅ KEY FIX: if worker returns a failure (including "crashed"), fall back if (!res.ok) { const fallback = mainThread(); - // If fallback succeeds, prefer it. If it also fails, return worker error. return fallback.ok ? fallback : res; } - return res; } catch { - // Worker unavailable/constructor throw/etc. return mainThread(); } } @@ -542,11 +509,19 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha const { auth } = useSigilAuth(); const sigilMeta = auth.meta; - // ✅ Latest aligned pulse (KaiStatus-identical source), without re-rendering KaiVoh + // Latest aligned pulse without re-render per pulse const kaiPulseRef = useRef(Number.NaN); - const [caption, setCaption] = useState(initialCaption); - const [author, setAuthor] = useState(initialAuthor); + // UNCONTROLLED text inputs (typing does not re-render) + const captionRef = useRef(initialCaption); + const authorRef = useRef(initialAuthor); + const sealTeaserRef = useRef(""); + const extraUrlFieldRef = useRef(""); + + const captionElRef = useRef(null); + const authorElRef = useRef(null); + const sealTeaserElRef = useRef(null); + const extraUrlElRef = useRef(null); const [bodyKind, setBodyKind] = useState("text"); const [codeLang, setCodeLang] = useState("tsx"); @@ -555,7 +530,6 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha const [phiKey, setPhiKey] = useState(""); const [kaiSignature, setKaiSignature] = useState(""); - const [extraUrlField, setExtraUrlField] = useState(""); const [extraUrls, setExtraUrls] = useState([]); const [files, setFiles] = useState([]); @@ -584,8 +558,7 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha /* 🔒 private seal states */ const [privateOn, setPrivateOn] = useState(false); const [sealMode, setSealMode] = useState("derived"); - const [sealTeaser, setSealTeaser] = useState(""); // optional public teaser - const [sealSalt, setSealSalt] = useState(() => makeSealSaltB64Url(18)); // derived mode salt + const [sealSalt, setSealSalt] = useState(() => makeSealSaltB64Url(18)); const [allowedGlyphs, setAllowedGlyphs] = useState([]); const [sealAdvanced, setSealAdvanced] = useState(false); @@ -595,14 +568,53 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha const dropRef = useRef(null); const hasVerifiedSigil = Boolean(sigilMeta); + // Debounced draft save (reads from refs + current state snapshot) + const scheduleDraftSave = (): void => { + if (!draftHydratedRef.current) return; + if (typeof window === "undefined") return; + + if (draftSaveTimerRef.current) window.clearTimeout(draftSaveTimerRef.current); + + draftSaveTimerRef.current = window.setTimeout(() => { + const draft: DraftState = { + v: 1, + caption: captionRef.current ?? "", + author: authorRef.current ?? "", + bodyKind, + codeLang, + htmlMode, + extraUrlField: extraUrlFieldRef.current ?? "", + extraUrls: extraUrls.map((item) => item.url), + privateOn, + sealMode, + sealTeaser: sealTeaserRef.current ?? "", + sealSalt, + allowedGlyphs, + sealAdvanced, + }; + + try { + window.sessionStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draft)); + } catch { + /* ignore */ + } + }, 450); + }; + + // Prop updates only affect refs if we haven't hydrated draft useEffect(() => { - if (!draftHydratedRef.current) setCaption(initialCaption); + if (draftHydratedRef.current) return; + captionRef.current = initialCaption; + if (captionElRef.current) captionElRef.current.value = initialCaption; }, [initialCaption]); useEffect(() => { - if (!draftHydratedRef.current) setAuthor(initialAuthor); + if (draftHydratedRef.current) return; + authorRef.current = initialAuthor; + if (authorElRef.current) authorElRef.current.value = initialAuthor; }, [initialAuthor]); + // Draft hydrate (ONE time) useEffect(() => { if (draftHydratedRef.current) return; draftHydratedRef.current = true; @@ -610,7 +622,14 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha if (typeof window === "undefined") return; const raw = window.sessionStorage.getItem(DRAFT_STORAGE_KEY); - if (!raw) return; + if (!raw) { + // Initialize DOM with provided props + captionRef.current = initialCaption; + authorRef.current = initialAuthor; + if (captionElRef.current) captionElRef.current.value = initialCaption; + if (authorElRef.current) authorElRef.current.value = initialAuthor; + return; + } try { const parsed = JSON.parse(raw) as DraftState; @@ -619,77 +638,50 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha const nextBodyKind = BODY_KIND_OPTIONS.includes(parsed.bodyKind) ? parsed.bodyKind : "text"; const nextHtmlMode = HTML_MODE_OPTIONS.includes(parsed.htmlMode) ? parsed.htmlMode : "code"; const nextSealMode = SEAL_MODE_OPTIONS.includes(parsed.sealMode) ? parsed.sealMode : "derived"; + const nextExtraUrls = readDraftStringArray(parsed.extraUrls) .map((url) => url.trim()) .filter((url) => isHttpUrl(url)) .map((url) => makeUrlAttachment({ url })); - setCaption(readDraftString(parsed.caption, initialCaption)); - setAuthor(readDraftString(parsed.author, initialAuthor)); + const cap = readDraftString(parsed.caption, initialCaption); + const auth = readDraftString(parsed.author, initialAuthor); + + captionRef.current = cap; + authorRef.current = auth; + + if (captionElRef.current) captionElRef.current.value = cap; + if (authorElRef.current) authorElRef.current.value = auth; + + const teaser = readDraftString(parsed.sealTeaser, ""); + sealTeaserRef.current = teaser; + if (sealTeaserElRef.current) sealTeaserElRef.current.value = teaser; + + const urlField = readDraftString(parsed.extraUrlField, ""); + extraUrlFieldRef.current = urlField; + if (extraUrlElRef.current) extraUrlElRef.current.value = urlField; + setBodyKind(nextBodyKind); setCodeLang(readDraftString(parsed.codeLang, "tsx")); setHtmlMode(nextHtmlMode); - setExtraUrlField(readDraftString(parsed.extraUrlField, "")); + setExtraUrls(nextExtraUrls); setPrivateOn(readDraftBool(parsed.privateOn, false)); setSealMode(nextSealMode); - setSealTeaser(readDraftString(parsed.sealTeaser, "")); setSealSalt(readDraftString(parsed.sealSalt, makeSealSaltB64Url(18))); setAllowedGlyphs(readDraftAllowedGlyphs(parsed.allowedGlyphs)); setSealAdvanced(readDraftBool(parsed.sealAdvanced, false)); } catch { - // ignore draft restore failures + /* ignore */ } - }, [initialAuthor, initialCaption]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Save draft when any STATE changes (caption/author handled by onInput) useEffect(() => { - if (!draftHydratedRef.current) return; - - if (draftSaveTimerRef.current) window.clearTimeout(draftSaveTimerRef.current); - - draftSaveTimerRef.current = window.setTimeout(() => { - const draft: DraftState = { - v: 1, - caption, - author, - bodyKind, - codeLang, - htmlMode, - extraUrlField, - extraUrls: extraUrls.map((item) => item.url), - privateOn, - sealMode, - sealTeaser, - sealSalt, - allowedGlyphs, - sealAdvanced, - }; - - try { - window.sessionStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draft)); - } catch { - // ignore draft save failures - } - }, 250); - - return () => { - if (draftSaveTimerRef.current) window.clearTimeout(draftSaveTimerRef.current); - }; - }, [ - caption, - author, - bodyKind, - codeLang, - htmlMode, - extraUrlField, - extraUrls, - privateOn, - sealMode, - sealTeaser, - sealSalt, - allowedGlyphs, - sealAdvanced, - ]); + scheduleDraftSave(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bodyKind, codeLang, htmlMode, extraUrls, privateOn, sealMode, sealSalt, allowedGlyphs, sealAdvanced]); useEffect(() => { attachmentsRef.current = attachments; @@ -739,18 +731,21 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha /* ───────────── Extra URL management ───────────── */ const addExtraUrl = (): void => { - const raw = extraUrlField.trim(); + const raw = (extraUrlFieldRef.current ?? "").trim(); if (!isHttpUrl(raw)) { setWarn("Invalid URL. Enter a full http(s) link."); return; } setExtraUrls((prev) => [...prev, makeUrlAttachment({ url: raw })]); - setExtraUrlField(""); + extraUrlFieldRef.current = ""; + if (extraUrlElRef.current) extraUrlElRef.current.value = ""; setWarn(null); + scheduleDraftSave(); }; const removeExtraUrl = (i: number): void => { setExtraUrls((prev) => prev.filter((_, idx) => idx !== i)); + scheduleDraftSave(); }; /* ───────────── File/Folder ingest ───────────── */ @@ -779,8 +774,6 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha for (const f of fileList) { const displayName = fileNameWithPath(f); - // 🔒 Private hard-guard: no cache-only file-ref allowed. - // Instead of creating file-ref, we SKIP large files and instruct URL upload. if (privateOn && f.size > MAX_INLINE_BYTES) { skippedLarge.push(displayName); continue; @@ -794,7 +787,7 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha type: f.type || "application/octet-stream", size: f.size, data_b64url: base64UrlEncodeBytes(buf), - }), + }) ); } else { const sha = await sha256FileHex(f); @@ -806,7 +799,7 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha type: f.type || "application/octet-stream", size: f.size, url, - }), + }) ); } } @@ -816,7 +809,7 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha const tail = skippedLarge.length > 3 ? ` (+${skippedLarge.length - 3} more)` : ""; setWarn( `Private (Sealed) mode cannot include cache-backed large files. Skipped: ${head}${tail}. ` + - `Attach as a URL instead (Drive/S3/IPFS/etc), or keep files ≤ ${prettyBytes(MAX_INLINE_BYTES)}.`, + `Attach as a URL instead (Drive/S3/IPFS/etc), or keep files ≤ ${prettyBytes(MAX_INLINE_BYTES)}.` ); } @@ -860,7 +853,6 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha } async function handleStoryCaptured(s: CapturedStory): Promise { - // 🔒 Private hard-guard: StoryRecorder produces cache-backed video (file-ref). if (privateOn) { setWarn("Private (Sealed) mode cannot include recorded stories (cache-backed video refs). Upload as a URL instead."); setStoryOpen(false); @@ -897,47 +889,10 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha setStoryOpen(false); } - /* ───────────── Payload body (v2) ───────────── */ - - const effectiveBodyText = caption.trim(); - - const postBody: PostBody | undefined = useMemo(() => { - if (!effectiveBodyText) return undefined; - - if (bodyKind === "text") return makeTextBody(effectiveBodyText); - if (bodyKind === "md") return makeMarkdownBody(effectiveBodyText); - if (bodyKind === "html") return makeHtmlBody(effectiveBodyText, htmlMode); - - const lang = codeLang.trim(); - return makeCodeBody(effectiveBodyText, lang ? lang : undefined); - }, [effectiveBodyText, bodyKind, codeLang, htmlMode]); - - const derivedCaption = useMemo((): string | undefined => { - if (!effectiveBodyText) return undefined; - - const one = firstLine(effectiveBodyText).trim(); - if (!one) return undefined; - - if (bodyKind === "code") { - const lang = codeLang.trim(); - const hint = lang ? `code:${lang}` : "code"; - return trunc(`${hint} — ${one}`, 220); - } - if (bodyKind === "md") return trunc(`md — ${one}`, 220); - if (bodyKind === "html") return trunc(`html — ${one}`, 220); - return trunc(one, 220); - }, [effectiveBodyText, bodyKind, codeLang]); - - /* ───────────── Private seal helpers ───────────── */ + /* ───────────── Private helpers ───────────── */ const hasFileRef = useMemo(() => attachmentsRef.current.items.some((it) => it.kind === "file-ref"), [attachments]); - const publicCaptionForPost = useMemo(() => { - if (!privateOn) return derivedCaption; - const t = sealTeaser.trim(); - return t ? trunc(t, 220) : "Sealed Memory"; - }, [privateOn, derivedCaption, sealTeaser]); - const canSealDerived = privateOn && sealMode === "derived" && hasVerifiedSigil && Boolean(kaiSignature.trim()); const canSealGlyph = privateOn && sealMode === "glyph" && allowedGlyphs.length > 0; @@ -1015,6 +970,7 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha return next; }); setWarn(null); + scheduleDraftSave(); } if (rejected.length > 0) { @@ -1026,9 +982,10 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha const removeAllowedGlyph = (idx: number): void => { setAllowedGlyphs((prev) => prev.filter((_, i) => i !== idx)); + scheduleDraftSave(); }; - /* ───────────── Generate payload/link (with lineage + registry) ───────────── */ + /* ───────────── Generate payload/link ───────────── */ const onGenerate = async (): Promise => { if (busy) return; @@ -1048,13 +1005,13 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha setWarn("Proof of Breath™ URL not detected; using fallback. Link generation will still work."); } - // 🔒 Private guard: do not allow cache-only file-ref attachments + // Private guard: no file-ref if (privateOn) { const mergedItemsPre: AttachmentItem[] = [...attachmentsRef.current.items, ...extraUrls]; if (mergedItemsPre.some((it) => it.kind === "file-ref")) { setErr( `Private (Sealed) mode cannot include cache-backed file refs. ` + - `Keep files ≤ ${prettyBytes(MAX_INLINE_BYTES)} (inline) or attach public URLs.`, + `Keep files ≤ ${prettyBytes(MAX_INLINE_BYTES)} (inline) or attach public URLs.` ); return; } @@ -1070,14 +1027,14 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha } } - // ✅ Pulse is aligned + identical to KaiStatus, but KaiVoh does NOT re-render per pulse. + // Pulse from mirror ref const pulse = kaiPulseRef.current; if (!Number.isFinite(pulse) || pulse < 0) { setErr("Failed to compute Kai pulse (aligned ticker not ready)."); return; } - // ✅ If payload requires a timestamp field, derive it FROM the pulse (not Date.now) + // Timestamp from pulse (not Date.now) const ts = toSafeNumberFromBigInt(epochMsFromPulse(pulse)); const t0 = nowMs(); @@ -1090,6 +1047,37 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha setStage("assemble"); + // Read current draft text without triggering renders + const effectiveBodyText = (captionRef.current ?? "").trim(); + const authorValue = (authorRef.current ?? "").trim(); + const teaserValue = (sealTeaserRef.current ?? "").trim(); + + const postBody: PostBody | undefined = (() => { + if (!effectiveBodyText) return undefined; + if (bodyKind === "text") return makeTextBody(effectiveBodyText); + if (bodyKind === "md") return makeMarkdownBody(effectiveBodyText); + if (bodyKind === "html") return makeHtmlBody(effectiveBodyText, htmlMode); + const lang = codeLang.trim(); + return makeCodeBody(effectiveBodyText, lang ? lang : undefined); + })(); + + const derivedCaption: string | undefined = (() => { + if (!effectiveBodyText) return undefined; + const one = firstLine(effectiveBodyText).trim(); + if (!one) return undefined; + + if (bodyKind === "code") { + const lang = codeLang.trim(); + const hint = lang ? `code:${lang}` : "code"; + return trunc(`${hint} — ${one}`, 220); + } + if (bodyKind === "md") return trunc(`md — ${one}`, 220); + if (bodyKind === "html") return trunc(`html — ${one}`, 220); + return trunc(one, 220); + })(); + + const publicCaptionForPost = !privateOn ? derivedCaption : teaserValue ? trunc(teaserValue, 220) : "Sealed Memory"; + const mergedItems: AttachmentItem[] = [...attachmentsRef.current.items, ...extraUrls]; const mergedAttachments = mergedItems.length > 0 ? makeAttachments(mergedItems) : undefined; @@ -1107,7 +1095,7 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha pulse, caption: publicCaptionForPost, body: postBody, - author: author.trim() ? author.trim() : undefined, + author: authorValue ? authorValue : undefined, source: "manual", sigilId, phiKey: hasVerifiedSigil && phiKey ? phiKey : undefined, @@ -1121,16 +1109,15 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha setStage("prepare"); const tPrep0 = nowMs(); - // Prepare attachments for link (inline/url only for private; normal path otherwise) const preparedFull = await withTimeout( preparePayloadForLink(basePayload, { cacheName: "sigil-attachments-v1", pathPrefix: "/att/" }), 20_000, - "preparePayloadForLink", + "preparePayloadForLink" ); const prepareMs = nowMs() - tPrep0; - // 🔒 If private: seal inner content (body + attachments) and remove plaintext from outer payload + // Private seal: seal inner (body + attachments) then remove plaintext from outer let payloadToEncode: FeedPostPayload = preparedFull; if (privateOn) { @@ -1168,21 +1155,17 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha }); } - const sealedOuter = { + payloadToEncode = { ...preparedFull, body: undefined, attachments: undefined, - // Attach the sealed envelope. This extends runtime payload shape. seal: envelope, } as unknown as FeedPostPayload; - - payloadToEncode = sealedOuter; } setStage("encode(worker)"); const tEnc0 = nowMs(); - // ✅ Worker-first encode (real module worker), deterministic fallback if needed const enc = await withTimeout(encodeTokenWorkerFirst(payloadToEncode), 30_000, "encodeTokenWithBudgets(worker)"); const encodeMs = nowMs() - tEnc0; @@ -1202,7 +1185,7 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha setErr( `Token encode failed: ${enc.error}. ` + `If you have a strict CSP, allow module workers from 'self' (worker-src 'self'). ` + - `This build uses a real worker file (no blob workers).`, + `This build uses a real worker file (no blob workers).` ); return; } @@ -1218,7 +1201,7 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha if (token.length > TOKEN_HARD_LIMIT) { setWarn( - `Token exceeds hard path limit (${token.length.toLocaleString()} > ${TOKEN_HARD_LIMIT.toLocaleString()}). Using hash URL to avoid request-line limits.`, + `Token exceeds hard path limit (${token.length.toLocaleString()} > ${TOKEN_HARD_LIMIT.toLocaleString()}). Using hash URL to avoid request-line limits.` ); } else if (token.length > TOKEN_SOFT_BUDGET) { setWarn(`Token is large (${token.length.toLocaleString()} chars). Prefer trimming inlined files or relying on external URLs.`); @@ -1252,11 +1235,7 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha } catch (e: unknown) { const msg = e instanceof Error ? e.message : typeof e === "string" ? e : "Failed to generate link."; setErr(msg); - setDiag({ - stage: stage || "unknown", - totalMs: nowMs() - t0, - note: msg, - }); + setDiag({ stage: stage || "unknown", totalMs: nowMs() - t0, note: msg }); } finally { setStage(""); setBusy(false); @@ -1264,14 +1243,24 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha }; const onReset = (): void => { - setCaption(initialCaption || ""); - setAuthor(initialAuthor || ""); + // reset uncontrolled fields (refs + DOM) + captionRef.current = initialCaption || ""; + authorRef.current = initialAuthor || ""; + sealTeaserRef.current = ""; + extraUrlFieldRef.current = ""; + + if (captionElRef.current) captionElRef.current.value = captionRef.current; + if (authorElRef.current) authorElRef.current.value = authorRef.current; + if (sealTeaserElRef.current) sealTeaserElRef.current.value = ""; + if (extraUrlElRef.current) extraUrlElRef.current.value = ""; + setBodyKind("text"); setCodeLang("tsx"); setHtmlMode("code"); - setExtraUrlField(""); + setExtraUrls([]); clearFiles(); + setErr(null); setWarn(null); setCopied(false); @@ -1281,8 +1270,7 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha setStage(""); setDiag(null); - // 🔒 Keep user’s sealing choices but reset content-adjacent fields - setSealTeaser(""); + // keep sealing choices but reset content-adjacent fields setSealAdvanced(false); if (storyPreview) { @@ -1293,15 +1281,10 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha try { window.sessionStorage.removeItem(DRAFT_STORAGE_KEY); } catch { - // ignore draft clear failures + /* ignore */ } }; - const bind = - (setter: (v: string) => void) => - (e: ChangeEvent): void => - setter(e.target.value); - /** Identity banner */ const identityBanner = useMemo(() => { if (!hasVerifiedSigil) return null; @@ -1345,9 +1328,7 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha
{!isLikelySigilUrl(sigilActionUrl) && ( -
- No canonical stream token detected in the URL. Fallback will still produce a valid post. -
+
No canonical stream token detected in the URL. Fallback will still produce a valid post.
)}
); @@ -1408,17 +1389,21 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha {privateOn && ( <>
- Private (Sealed) encrypts body + attachments inside the token. The outer post remains verifiable (ΦKey/ΣSig) - but does not contain plaintext content. + Private (Sealed) encrypts body + attachments inside the token. The outer post remains verifiable (ΦKey/ΣSig) but + does not contain plaintext content.
{ + sealTeaserRef.current = e.currentTarget.value; + scheduleDraftSave(); + }} placeholder="What should be visible without unlocking?" maxLength={240} /> @@ -1432,12 +1417,7 @@ export default function KaiVoh({ initialCaption = "", initialAuthor = "", onExha
-