Loading…
@@ -223,6 +238,15 @@ export default function DeleteRecordPage(props: { params: { collection: string }
);
}
+ if (initialRecordResolved && !record) {
+ return (
+
+
Not found
+
No record returned.
+
+ );
+ }
+
const owner = record ? getValue(record, 'owner', 3) : null;
return (
diff --git a/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx b/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx
index 84333da..dae157f 100644
--- a/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx
+++ b/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx
@@ -6,9 +6,9 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { fetchAppAbi } from '../../../src/lib/abi';
import { assertAbiFunction, fnGet, fnUpdate } from '../../../src/lib/app';
import { chainFromId } from '../../../src/lib/chains';
-import { makePublicClient } from '../../../src/lib/clients';
+import { chainWithRpcOverride, makePublicClient } from '../../../src/lib/clients';
import { formatNumeric, parseFieldValue } from '../../../src/lib/format';
-import { fetchManifest, getPrimaryDeployment } from '../../../src/lib/manifest';
+import { fetchManifest, getPrimaryDeployment, getReadRpcUrl } from '../../../src/lib/manifest';
import { getCollection, mutableFields, type ThsCollection, type ThsField } from '../../../src/lib/ths';
import { submitWriteTx } from '../../../src/lib/tx';
import TxStatus, { type TxPhase } from '../../../src/components/TxStatus';
@@ -77,8 +77,8 @@ export default function EditRecordPage(props: { params: { collection: string } }
const manifest = await fetchManifest();
const d = getPrimaryDeployment(manifest);
if (!d) throw new Error('Manifest has no deployments');
- const chain = chainFromId(Number(d.chainId));
- const pc = makePublicClient(chain, rpcOverride);
+ const chain = chainWithRpcOverride(chainFromId(Number(d.chainId)), rpcOverride || getReadRpcUrl(manifest) || undefined);
+ const pc = makePublicClient(chain, rpcOverride || getReadRpcUrl(manifest) || undefined);
setManifest(manifest);
setDeployment(d);
setPublicClient(pc);
@@ -159,7 +159,10 @@ export default function EditRecordPage(props: { params: { collection: string } }
setTxHash(null);
try {
- const chain = chainFromId(Number(deployment.chainId));
+ const chain = chainWithRpcOverride(
+ chainFromId(Number(deployment.chainId)),
+ rpcOverride || getReadRpcUrl(manifest) || undefined
+ );
const contractArgs: any[] = [id];
for (const f of fields) {
diff --git a/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx b/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx
index 539ee4b..2a5a17e 100644
--- a/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx
+++ b/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx
@@ -6,9 +6,9 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { fetchAppAbi } from '../../../src/lib/abi';
import { assertAbiFunction, fnCreate } from '../../../src/lib/app';
import { chainFromId } from '../../../src/lib/chains';
-import { makePublicClient } from '../../../src/lib/clients';
+import { chainWithRpcOverride, makePublicClient } from '../../../src/lib/clients';
import { formatWei, parseFieldValue } from '../../../src/lib/format';
-import { fetchManifest, getPrimaryDeployment } from '../../../src/lib/manifest';
+import { fetchManifest, getPrimaryDeployment, getReadRpcUrl } from '../../../src/lib/manifest';
import { createFields, getCollection, hasCreatePayment, requiredFieldNames, type ThsField } from '../../../src/lib/ths';
import { submitWriteTx } from '../../../src/lib/tx';
import TxStatus, { type TxPhase } from '../../../src/components/TxStatus';
@@ -48,8 +48,8 @@ export default function CreateRecordPage(props: { params: { collection: string }
const manifest = await fetchManifest();
const d = getPrimaryDeployment(manifest);
if (!d) throw new Error('Manifest has no deployments');
- const chain = chainFromId(Number(d.chainId));
- const pc = makePublicClient(chain, rpcOverride);
+ const chain = chainWithRpcOverride(chainFromId(Number(d.chainId)), rpcOverride || getReadRpcUrl(manifest) || undefined);
+ const pc = makePublicClient(chain, rpcOverride || getReadRpcUrl(manifest) || undefined);
setManifest(manifest);
setDeployment(d);
@@ -128,7 +128,10 @@ export default function CreateRecordPage(props: { params: { collection: string }
}
try {
- const chain = chainFromId(Number(deployment.chainId));
+ const chain = chainWithRpcOverride(
+ chainFromId(Number(deployment.chainId)),
+ rpcOverride || getReadRpcUrl(manifest) || undefined
+ );
assertAbiFunction(abi, fnCreate(collectionName), collectionName);
const contractInput = Object.fromEntries(
fields.map((f) => [f.name, parseFieldValue(form[f.name] ?? '', f.type, (f as any).decimals)])
diff --git a/packages/templates/next-export-ui/app/[collection]/view/ClientPage.tsx b/packages/templates/next-export-ui/app/[collection]/view/ClientPage.tsx
index 07966fa..c28c60f 100644
--- a/packages/templates/next-export-ui/app/[collection]/view/ClientPage.tsx
+++ b/packages/templates/next-export-ui/app/[collection]/view/ClientPage.tsx
@@ -6,9 +6,9 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { fetchAppAbi } from '../../../src/lib/abi';
import { assertAbiFunction, collectionId, fnGet, fnTransfer } from '../../../src/lib/app';
import { chainFromId } from '../../../src/lib/chains';
-import { makePublicClient } from '../../../src/lib/clients';
-import { formatNumeric, shortAddress } from '../../../src/lib/format';
-import { fetchManifest, getPrimaryDeployment } from '../../../src/lib/manifest';
+import { chainWithRpcOverride, makePublicClient } from '../../../src/lib/clients';
+import { formatDateTime, formatFieldValue, shortAddress } from '../../../src/lib/format';
+import { fetchManifest, getPrimaryDeployment, getReadRpcUrl } from '../../../src/lib/manifest';
import { fieldLinkUi, getCollection, transferEnabled, type ThsCollection, type ThsField } from '../../../src/lib/ths';
import { submitWriteTx } from '../../../src/lib/tx';
import TxStatus, { type TxPhase } from '../../../src/components/TxStatus';
@@ -57,7 +57,9 @@ export default function ViewRecordPage(props: { params: { collection: string } }
const idParam = search.get('id');
const rpcOverride = search.get('rpc') ?? undefined;
- const [loading, setLoading] = useState(true);
+ const [bootstrapping, setBootstrapping] = useState(true);
+ const [recordLoading, setRecordLoading] = useState(false);
+ const [initialRecordResolved, setInitialRecordResolved] = useState(false);
const [error, setError] = useState
(null);
const [deployment, setDeployment] = useState(null);
const [manifest, setManifest] = useState(null);
@@ -81,14 +83,15 @@ export default function ViewRecordPage(props: { params: { collection: string } }
useEffect(() => {
async function boot() {
- setLoading(true);
+ setBootstrapping(true);
+ setInitialRecordResolved(false);
setError(null);
try {
const manifest = await fetchManifest();
const d = getPrimaryDeployment(manifest);
if (!d) throw new Error('Manifest has no deployments');
- const chain = chainFromId(Number(d.chainId));
- const pc = makePublicClient(chain, rpcOverride);
+ const chain = chainWithRpcOverride(chainFromId(Number(d.chainId)), rpcOverride || getReadRpcUrl(manifest) || undefined);
+ const pc = makePublicClient(chain, rpcOverride || getReadRpcUrl(manifest) || undefined);
setManifest(manifest);
setDeployment(d);
setPublicClient(pc);
@@ -103,7 +106,7 @@ export default function ViewRecordPage(props: { params: { collection: string } }
} catch (e: any) {
setError(String(e?.message ?? e));
} finally {
- setLoading(false);
+ setBootstrapping(false);
}
}
void boot();
@@ -111,13 +114,16 @@ export default function ViewRecordPage(props: { params: { collection: string } }
const appAddress = deployment?.deploymentEntrypointAddress as `0x${string}` | undefined;
- async function fetchRecord() {
+ async function fetchRecord(options?: { initial?: boolean }) {
if (!publicClient || !abi || !appAddress || id === null) return;
if (appAddress.toLowerCase() === '0x0000000000000000000000000000000000000000') {
setError('App is not deployed yet (manifest has 0x0 address).');
return;
}
+ const isInitial = options?.initial === true;
+ if (isInitial) setInitialRecordResolved(false);
+ setRecordLoading(true);
setError(null);
try {
assertAbiFunction(abi, fnGet(collectionName), collectionName);
@@ -130,11 +136,14 @@ export default function ViewRecordPage(props: { params: { collection: string } }
setRecord(r);
} catch (e: any) {
setError(String(e?.message ?? e));
+ } finally {
+ setRecordLoading(false);
+ if (isInitial) setInitialRecordResolved(true);
}
}
useEffect(() => {
- void fetchRecord();
+ void fetchRecord({ initial: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [publicClient, abi, appAddress, idParam]);
@@ -172,7 +181,10 @@ export default function ViewRecordPage(props: { params: { collection: string } }
setTxHash(null);
try {
- const chain = chainFromId(Number(deployment.chainId));
+ const chain = chainWithRpcOverride(
+ chainFromId(Number(deployment.chainId)),
+ rpcOverride || getReadRpcUrl(manifest) || undefined
+ );
assertAbiFunction(abi, fnTransfer(collectionName), collectionName);
const result = await submitWriteTx({
@@ -224,7 +236,7 @@ export default function ViewRecordPage(props: { params: { collection: string } }
);
}
- if (loading && !record) {
+ if ((bootstrapping || (recordLoading && !initialRecordResolved)) && !record) {
return (
Loading…
@@ -265,7 +277,7 @@ export default function ViewRecordPage(props: { params: { collection: string } }
);
}
- if (!record) {
+ if (initialRecordResolved && !record) {
return (
Not found
@@ -304,7 +316,7 @@ export default function ViewRecordPage(props: { params: { collection: string } }
createdBy
{createdBy ? shortAddress(String(createdBy)) : '—'}
createdAt
-
{createdAt ? String(createdAt) : '—'}
+
{createdAt ? formatDateTime(createdAt) : '—'}
version
{version ? String(version) : '—'}
@@ -315,7 +327,7 @@ export default function ViewRecordPage(props: { params: { collection: string } }
{collection.fields.map((f) => {
const v = getValue(record, f.name, fieldIndex(collection, f));
- const rendered = formatNumeric(v, f.type, (f as any).decimals);
+ const rendered = formatFieldValue(v, f.type, (f as any).decimals, f.name);
return (
{f.name}
diff --git a/packages/templates/next-export-ui/app/globals.css b/packages/templates/next-export-ui/app/globals.css
index 6dd2586..ae96912 100644
--- a/packages/templates/next-export-ui/app/globals.css
+++ b/packages/templates/next-export-ui/app/globals.css
@@ -1,44 +1,134 @@
+@property --th-boil-x1 {
+ syntax: '';
+ inherits: false;
+ initial-value: 10%;
+}
+
+@property --th-boil-y1 {
+ syntax: '';
+ inherits: false;
+ initial-value: 20%;
+}
+
+@property --th-boil-x2 {
+ syntax: '';
+ inherits: false;
+ initial-value: 90%;
+}
+
+@property --th-boil-y2 {
+ syntax: '';
+ inherits: false;
+ initial-value: 80%;
+}
+
+@property --th-boil-x3 {
+ syntax: '';
+ inherits: false;
+ initial-value: 50%;
+}
+
+@property --th-boil-y3 {
+ syntax: '';
+ inherits: false;
+ initial-value: 50%;
+}
+
:root {
- --th-bg: #ffffff;
- --th-bg-alt: #eef4ff;
- --th-panel: #eff5ff;
- --th-panel-strong: #e3edff;
- --th-border: #bed4ff;
- --th-text: #0a255f;
- --th-muted: #486ca6;
- --th-primary: #0f56e0;
- --th-primary-strong: #0943b8;
- --th-accent: #ffc700;
- --th-success: #09995a;
- --th-danger: #c52f44;
- --th-radius-sm: 10px;
- --th-radius-md: 14px;
- --th-radius-lg: 20px;
+ color-scheme: light;
+ --th-bg: hsl(0 0% 100%);
+ --th-bg-alt: hsl(0 0% 100%);
+ --th-panel: hsl(0 0% 100%);
+ --th-panel-strong: hsl(0 0% 100%);
+ --th-panel-muted: hsl(210 40% 94%);
+ --th-border: hsl(214 32% 88%);
+ --th-border-strong: hsl(214 32% 85%);
+ --th-text: hsl(222 47% 11%);
+ --th-muted: hsl(215 16% 42%);
+ --th-primary: hsl(190 100% 50%);
+ --th-primary-strong: hsl(190 100% 45%);
+ --th-primary-foreground: hsl(190 100% 10%);
+ --th-accent: hsl(300 100% 75%);
+ --th-success: hsl(142 70% 35%);
+ --th-danger: hsl(0 84% 60%);
+ --th-grid: hsl(190 100% 50% / 0.05);
+ --th-grid-strong: hsl(190 100% 50% / 0.75);
+ --th-glow-left: hsl(190 100% 50% / 0.18);
+ --th-glow-right: hsl(300 100% 75% / 0.18);
+ --th-shadow: 4px 4px 0 rgba(0, 0, 0, 0.03);
+ --th-shadow-soft: 4px 4px 0 rgba(0, 0, 0, 0.03);
+ --th-badge-bg: hsl(0 0% 100%);
+ --th-input-bg: hsl(0 0% 100%);
+ --th-danger-surface: hsl(0 100% 98%);
+ --th-danger-border: hsl(0 84% 84%);
+ --th-industrial-bracket-opacity: 95%;
+ --th-radius-sm: 0px;
+ --th-radius-md: 0px;
+ --th-radius-lg: 0px;
+ --th-shell-width: 1280px;
--th-space-xs: 6px;
--th-space-sm: 10px;
--th-space-md: 16px;
--th-space-lg: 24px;
- --th-space-xl: 36px;
+ --th-space-xl: 38px;
--th-font-display: "Montserrat", "Avenir Next", "Segoe UI", sans-serif;
- --th-font-body: "Inter", "Segoe UI", sans-serif;
- --th-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
- --th-motion-fast: 120ms;
- --th-motion-base: 180ms;
+ --th-font-body: "Montserrat", "Avenir Next", "Segoe UI", sans-serif;
+ --th-font-mono: "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ --th-motion-fast: 140ms;
+ --th-motion-base: 220ms;
}
-* { box-sizing: border-box; }
+html[data-theme='dark'] {
+ color-scheme: dark;
+ --th-bg: hsl(240 15% 4%);
+ --th-bg-alt: hsl(240 15% 4%);
+ --th-panel: hsl(240 10% 6%);
+ --th-panel-strong: hsl(240 10% 6%);
+ --th-panel-muted: hsl(240 10% 8%);
+ --th-border: hsl(240 6% 15%);
+ --th-border-strong: hsl(240 6% 22%);
+ --th-text: hsl(180 20% 90%);
+ --th-muted: hsl(240 5% 60%);
+ --th-primary: hsl(300 100% 75%);
+ --th-primary-strong: hsl(300 86% 69%);
+ --th-primary-foreground: hsl(300 100% 10%);
+ --th-accent: hsl(190 100% 50%);
+ --th-success: hsl(142 70% 45%);
+ --th-danger: hsl(0 100% 60%);
+ --th-grid: hsl(300 100% 75% / 0.04);
+ --th-grid-strong: hsl(300 100% 75% / 0.75);
+ --th-glow-left: hsl(190 100% 50% / 0.14);
+ --th-glow-right: hsl(300 100% 60% / 0.12);
+ --th-shadow: none;
+ --th-shadow-soft: none;
+ --th-badge-bg: hsl(240 10% 8%);
+ --th-input-bg: hsl(240 6% 12%);
+ --th-danger-surface: hsl(0 60% 14%);
+ --th-danger-border: hsl(0 60% 28%);
+ --th-industrial-bracket-opacity: 100%;
+}
-html, body {
- height: 100%;
+* {
+ box-sizing: border-box;
}
+html,
body {
- margin: 0;
- background: var(--th-bg);
+ min-height: 100%;
+}
+
+body {
+ background-color: var(--th-bg);
+ background-image:
+ linear-gradient(to right, var(--th-grid) 1px, transparent 1px),
+ linear-gradient(to bottom, var(--th-grid) 1px, transparent 1px);
+ background-size: 30px 30px;
+ background-attachment: fixed;
color: var(--th-text);
font-family: var(--th-font-body);
- position: relative;
+ margin: 0;
overflow-x: hidden;
+ position: relative;
}
a {
@@ -46,314 +136,1300 @@ a {
text-decoration: none;
}
+img,
+svg {
+ display: block;
+ max-width: 100%;
+}
+
+.srOnly {
+ border: 0;
+ clip: rect(0, 0, 0, 0);
+ height: 1px;
+ margin: -1px;
+ overflow: hidden;
+ padding: 0;
+ position: absolute;
+ white-space: nowrap;
+ width: 1px;
+}
+
+.siteBackground {
+ inset: 0;
+ overflow: hidden;
+ pointer-events: none;
+ position: fixed;
+ z-index: 0;
+}
+
+.siteGridLayer {
+ inset: 0;
+ position: absolute;
+ background-image:
+ linear-gradient(to right, color-mix(in srgb, var(--th-primary) 16%, transparent) 29px, transparent 29px),
+ linear-gradient(to bottom, color-mix(in srgb, var(--th-primary) 16%, transparent) 29px, transparent 29px),
+ linear-gradient(to right, color-mix(in srgb, var(--th-primary) 75%, transparent) 2px, transparent 2px),
+ linear-gradient(to bottom, color-mix(in srgb, var(--th-primary) 75%, transparent) 2px, transparent 2px);
+ background-repeat: repeat;
+ background-size: 30px 30px, 30px 30px, 30px 30px, 30px 30px;
+ -webkit-mask-image:
+ radial-gradient(circle at var(--th-boil-x1) var(--th-boil-y1), black, transparent 55%),
+ radial-gradient(circle at var(--th-boil-x2) var(--th-boil-y2), black, transparent 55%),
+ radial-gradient(circle at var(--th-boil-x3) var(--th-boil-y3), black, transparent 65%);
+ mask-image:
+ radial-gradient(circle at var(--th-boil-x1) var(--th-boil-y1), black, transparent 55%),
+ radial-gradient(circle at var(--th-boil-x2) var(--th-boil-y2), black, transparent 55%),
+ radial-gradient(circle at var(--th-boil-x3) var(--th-boil-y3), black, transparent 65%);
+ -webkit-mask-composite: source-over;
+ mask-composite: add;
+ -webkit-mask-repeat: no-repeat;
+ mask-repeat: no-repeat;
+ animation:
+ boilSpotOne 13s ease-in-out infinite alternate,
+ boilSpotTwo 17s ease-in-out infinite alternate,
+ boilSpotThree 19s ease-in-out infinite alternate,
+ gridBreathe 6s ease-in-out infinite;
+}
+
+.siteLivingGridCanvas {
+ inset: 0;
+ opacity: 0.7;
+ pointer-events: none;
+ position: absolute;
+}
+
+html[data-theme='dark'] .siteLivingGridCanvas {
+ opacity: 0.9;
+}
+
.container {
- max-width: 1100px;
- margin: 0 auto;
- padding: var(--th-space-lg) var(--th-space-md) 64px;
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
position: relative;
z-index: 1;
- min-height: 100vh;
+}
+
+.navShell {
+ left: 0;
+ position: fixed;
+ right: 0;
+ top: 0;
+ z-index: 40;
+}
+
+.navShell::before {
+ background: var(--th-panel);
+ border-bottom: 1px solid var(--th-border);
+ content: '';
+ inset: 0;
+ position: absolute;
+}
+
+.navShell::after {
+ background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--th-primary) 58%, transparent), transparent);
+ bottom: 0;
+ content: '';
+ height: 1px;
+ left: 50%;
+ opacity: 0.55;
+ position: absolute;
+ transform: translateX(-50%);
+ width: min(52vw, 680px);
+}
+
+.card,
+.heroDataPanel,
+.heroStat,
+.recordPreviewCell,
+.themeToggle {
+ backdrop-filter: blur(8px);
+ background: color-mix(in srgb, var(--th-panel) 95%, transparent);
+ border: 1px solid var(--th-border);
+ box-shadow:
+ var(--th-shadow),
+ 0 0 0 1px color-mix(in srgb, var(--th-primary) 20%, transparent);
+ position: relative;
+}
+
+.card::before,
+.card::after,
+.heroDataPanel::before,
+.heroDataPanel::after,
+.heroStat::before,
+.heroStat::after,
+.recordPreviewCell::before,
+.recordPreviewCell::after,
+.themeToggle::before,
+.themeToggle::after {
+ content: '';
+ height: 22px;
+ pointer-events: none;
+ position: absolute;
+ transition: border-color var(--th-motion-base) ease;
+ width: 22px;
+}
+
+.card::before,
+.heroDataPanel::before,
+.heroStat::before,
+.recordPreviewCell::before,
+.themeToggle::before {
+ border-left: 1.5px solid color-mix(in srgb, var(--th-primary) var(--th-industrial-bracket-opacity), transparent);
+ border-top: 1.5px solid color-mix(in srgb, var(--th-primary) var(--th-industrial-bracket-opacity), transparent);
+ left: -1px;
+ top: -1px;
+}
+
+.card::after,
+.heroDataPanel::after,
+.heroStat::after,
+.recordPreviewCell::after,
+.themeToggle::after {
+ border-bottom: 1.5px solid color-mix(in srgb, var(--th-primary) var(--th-industrial-bracket-opacity), transparent);
+ border-right: 1.5px solid color-mix(in srgb, var(--th-primary) var(--th-industrial-bracket-opacity), transparent);
+ bottom: -1px;
+ right: -1px;
+}
+
+html[data-theme='dark'] .card::before,
+html[data-theme='dark'] .card::after,
+html[data-theme='dark'] .heroDataPanel::before,
+html[data-theme='dark'] .heroDataPanel::after,
+html[data-theme='dark'] .heroStat::before,
+html[data-theme='dark'] .heroStat::after,
+html[data-theme='dark'] .recordPreviewCell::before,
+html[data-theme='dark'] .recordPreviewCell::after,
+html[data-theme='dark'] .themeToggle::before,
+html[data-theme='dark'] .themeToggle::after {
+ filter: drop-shadow(0 0 4px color-mix(in srgb, var(--th-primary) 60%, transparent));
+}
+
+.glassPanel {
+ backdrop-filter: blur(8px);
+ background: color-mix(in srgb, var(--th-panel) 95%, transparent);
+ box-shadow:
+ 4px 4px 0 0 rgba(0, 0, 0, 0.03),
+ 0 0 0 1px color-mix(in srgb, var(--th-primary) 20%, transparent);
+}
+
+.glass-panel,
+.cg-glass-panel {
+ backdrop-filter: blur(8px);
+ background: color-mix(in srgb, var(--th-panel) 95%, transparent);
+ box-shadow:
+ 4px 4px 0 0 rgba(0, 0, 0, 0.03),
+ 0 0 0 1px color-mix(in srgb, var(--th-primary) 20%, transparent);
+}
+
+.industrialBorder {
+ border: 1px solid var(--th-border);
+ position: relative;
+}
+
+.industrial-border,
+.cg-industrial-border {
+ border: 1px solid var(--th-border);
+ position: relative;
+}
+
+.industrialBorder::before,
+.industrialBorder::after {
+ content: '';
+ position: absolute;
+ width: 1.5rem;
+ height: 1.5rem;
+ pointer-events: none;
+}
+
+.industrial-border::before,
+.industrial-border::after,
+.cg-industrial-border::before,
+.cg-industrial-border::after {
+ content: '';
+ position: absolute;
+ width: 1.5rem;
+ height: 1.5rem;
+ pointer-events: none;
+}
+
+.industrialBorder::before {
+ top: -1px;
+ left: -1px;
+ border-top: 1.5px solid color-mix(in srgb, var(--th-primary) var(--th-industrial-bracket-opacity), transparent);
+ border-left: 1.5px solid color-mix(in srgb, var(--th-primary) var(--th-industrial-bracket-opacity), transparent);
+}
+
+.industrial-border::before,
+.cg-industrial-border::before {
+ top: -1px;
+ left: -1px;
+ border-top: 1.5px solid color-mix(in srgb, var(--th-primary) var(--th-industrial-bracket-opacity), transparent);
+ border-left: 1.5px solid color-mix(in srgb, var(--th-primary) var(--th-industrial-bracket-opacity), transparent);
+}
+
+.industrialBorder::after {
+ right: -1px;
+ bottom: -1px;
+ border-right: 1.5px solid color-mix(in srgb, var(--th-primary) var(--th-industrial-bracket-opacity), transparent);
+ border-bottom: 1.5px solid color-mix(in srgb, var(--th-primary) var(--th-industrial-bracket-opacity), transparent);
+}
+
+.industrial-border::after,
+.cg-industrial-border::after {
+ right: -1px;
+ bottom: -1px;
+ border-right: 1.5px solid color-mix(in srgb, var(--th-primary) var(--th-industrial-bracket-opacity), transparent);
+ border-bottom: 1.5px solid color-mix(in srgb, var(--th-primary) var(--th-industrial-bracket-opacity), transparent);
+}
+
+.industrialBorderAccent::before {
+ border-top-color: color-mix(in srgb, var(--th-accent) var(--th-industrial-bracket-opacity), transparent);
+ border-left-color: color-mix(in srgb, var(--th-accent) var(--th-industrial-bracket-opacity), transparent);
+}
+
+.industrial-border-accent::before,
+.cg-industrial-border-accent::before {
+ border-top-color: color-mix(in srgb, var(--th-accent) var(--th-industrial-bracket-opacity), transparent);
+ border-left-color: color-mix(in srgb, var(--th-accent) var(--th-industrial-bracket-opacity), transparent);
+}
+
+.industrialBorderAccent::after {
+ border-right-color: color-mix(in srgb, var(--th-accent) var(--th-industrial-bracket-opacity), transparent);
+ border-bottom-color: color-mix(in srgb, var(--th-accent) var(--th-industrial-bracket-opacity), transparent);
+}
+
+.industrial-border-accent::after,
+.cg-industrial-border-accent::after {
+ border-right-color: color-mix(in srgb, var(--th-accent) var(--th-industrial-bracket-opacity), transparent);
+ border-bottom-color: color-mix(in srgb, var(--th-accent) var(--th-industrial-bracket-opacity), transparent);
+}
+
+.sectionLabel {
+ color: var(--th-muted);
+ font-family: var(--th-font-mono);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.2em;
+ text-transform: uppercase;
+}
+
+.nil-section-label,
+.cg-section-label {
+ color: var(--th-muted);
+ font-family: var(--th-font-mono);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.2em;
+ text-transform: uppercase;
+}
+
+.nav {
+ align-items: center;
+ display: flex;
+ gap: 16px;
+ justify-content: space-between;
+ margin: 0 auto;
+ max-width: var(--th-shell-width);
+ min-height: 72px;
+ padding: 0 16px;
+ position: relative;
+ width: 100%;
+}
+
+.brand {
+ display: flex;
+ flex: 0 1 290px;
+ min-width: 0;
+}
+
+.brandCopy {
display: flex;
flex-direction: column;
+ gap: 0;
+ justify-content: center;
+ min-width: 0;
+ padding-block: 8px;
+}
+
+.brandIdentity {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+.brandWordText {
+ display: inline-flex;
+ font-size: 16px;
+ font-weight: 800;
+ gap: 6px;
+ letter-spacing: 0.18em;
+ line-height: 1.08;
+ text-transform: uppercase;
+}
+
+.brandWordBase {
+ color: var(--th-text);
+}
+
+.brandWordAccent {
+ color: var(--th-primary);
+}
+
+.eyebrow {
+ color: var(--th-muted);
+ display: inline-flex;
+ font-family: var(--th-font-mono);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.2em;
+ text-transform: uppercase;
}
-.topBackground {
+.navRail {
+ align-items: center;
+ background: var(--th-panel);
+ border: 1px solid var(--th-border);
+ display: flex;
+ flex: 1 1 auto;
+ gap: 2px;
+ justify-content: center;
+ min-width: 0;
+ padding: 6px 8px;
+ position: relative;
+}
+
+.navRail::before,
+.navRail::after {
+ content: '';
+ height: 16px;
position: absolute;
- z-index: -1;
- top: -30vw;
- left: -10vh;
- width: 150vw;
- min-width: 600px;
- max-width: 150vw;
- display: inline-block;
- height: 200vh;
- background-image: url('/static/media/Token%20BIG-01.cfe69f33.png');
- background-position: 0 0;
- background-size: contain;
- background-repeat: no-repeat;
+ width: 16px;
}
-.nav {
+.navRail::before {
+ border-left: 1.5px solid var(--th-primary);
+ border-top: 1.5px solid var(--th-primary);
+ left: -1px;
+ top: -1px;
+}
+
+.navRail::after {
+ border-bottom: 1.5px solid var(--th-primary);
+ border-right: 1.5px solid var(--th-primary);
+ bottom: -1px;
+ right: -1px;
+}
+
+.navRailLink {
+ align-items: center;
+ color: var(--th-muted);
+ display: inline-flex;
+ font-family: var(--th-font-mono);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.18em;
+ min-height: 30px;
+ padding: 0 12px;
+ text-transform: uppercase;
+ transition:
+ background-color var(--th-motion-fast) ease,
+ color var(--th-motion-fast) ease;
+ white-space: nowrap;
+}
+
+.navRailLink:hover,
+.navRailLink:focus-visible {
+ background: var(--th-panel-muted);
+ color: var(--th-text);
+ outline: none;
+}
+
+.controlCluster {
+ align-items: center;
display: flex;
+ flex-wrap: wrap;
+ flex: 0 0 auto;
+ gap: 12px;
+ justify-content: flex-end;
+}
+
+.navCta {
+ min-height: 40px;
+ padding-inline: 18px;
+}
+
+.themeToggle {
+ appearance: none;
+ background-color: var(--th-panel);
+ color: var(--th-muted);
+ cursor: pointer;
+ display: grid;
+ height: 38px;
+ place-items: center;
+ padding: 0;
+ transition:
+ background-color var(--th-motion-base) ease,
+ color var(--th-motion-base) ease,
+ transform var(--th-motion-fast) ease;
+ width: 38px;
+}
+
+.themeToggle:hover {
+ background-color: var(--th-panel-muted);
+ color: var(--th-text);
+}
+
+.themeToggle:active {
+ transform: translate(1px, 1px);
+}
+
+.themeToggleIcon {
+ --theme-toggle-offset-x: 0px;
+ inset: 0;
+ margin: auto;
+ position: absolute;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 18px;
+ pointer-events: none;
+ transition:
+ opacity var(--th-motion-base) ease,
+ transform var(--th-motion-base) ease;
+ width: 18px;
+}
+
+.themeToggleMoon {
+ --theme-toggle-offset-x: -0.75px;
+}
+
+.themeToggleIcon svg {
+ height: 18px;
+ width: 18px;
+}
+
+.themeToggleIcon.hidden {
+ opacity: 0;
+ transform: translateX(var(--theme-toggle-offset-x)) rotate(90deg) scale(0);
+}
+
+.themeToggleIcon.visible {
+ opacity: 1;
+ transform: translateX(var(--theme-toggle-offset-x)) rotate(0deg) scale(1);
+}
+
+.statusStack {
+ align-items: flex-end;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.statusInline {
align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ justify-content: flex-end;
+}
+
+.mainShell {
+ display: flex;
+ flex-direction: column;
+ flex: 1 0 auto;
+ gap: 24px;
+ padding-top: 96px;
+}
+
+.siteContent {
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+ margin: 0 auto;
+ max-width: var(--th-shell-width);
+ padding: 0 16px;
+ width: 100%;
+}
+
+.siteShell {
+ margin: 0 auto;
+ max-width: var(--th-shell-width);
+ padding: 48px 16px 28px;
+ position: relative;
+ text-align: center;
+ width: 100%;
+}
+
+.pageStack {
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+}
+
+.grid {
+ display: grid;
+ gap: 18px;
+ grid-template-columns: repeat(12, minmax(0, 1fr));
+}
+
+.card {
+ grid-column: span 12;
+ padding: 20px;
+}
+
+.card h2 {
+ font-family: var(--th-font-display);
+ font-size: clamp(1.6rem, 3vw, 2.15rem);
+ font-weight: 800;
+ letter-spacing: -0.04em;
+ line-height: 1.04;
+ margin: 0;
+}
+
+.heroPanel,
+.collectionHero {
+ padding: clamp(22px, 4vw, 34px);
+}
+
+.heroSplit {
+ display: grid;
+ gap: 22px;
+}
+
+.heroTopline,
+.chipRow,
+.heroMeta,
+.actionGroup,
+.fieldPillRow {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.heroTopline {
justify-content: space-between;
- gap: var(--th-space-sm);
- padding: var(--th-space-md);
+}
+
+.displayTitle {
+ font-family: var(--th-font-display);
+ font-size: clamp(2.4rem, 6vw, 4.7rem);
+ font-weight: 900;
+ letter-spacing: -0.06em;
+ line-height: 0.94;
+ margin: 14px 0 0;
+ max-width: 9ch;
+}
+
+.displayTitle span {
+ color: var(--th-primary);
+}
+
+.lead {
+ color: var(--th-muted);
+ font-size: 0.98rem;
+ line-height: 1.75;
+ margin: 16px 0 0;
+ max-width: 54ch;
+}
+
+.heroDataPanel {
+ background: var(--th-panel-muted);
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ justify-content: space-between;
+ padding: 18px;
+}
+
+.heroStatGrid {
+ display: grid;
+ gap: 8px;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.heroStat {
+ background: var(--th-panel-strong);
+ min-height: 92px;
+ padding: 14px 14px 12px;
+}
+
+.heroStatValue {
+ font-family: var(--th-font-mono);
+ font-size: clamp(1.6rem, 4vw, 2.15rem);
+ font-weight: 800;
+ letter-spacing: -0.06em;
+}
+
+.heroStatLabel {
+ color: var(--th-muted);
+ font-family: var(--th-font-mono);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.2em;
+ margin-top: 10px;
+ text-transform: uppercase;
+}
+
+.featureGrid {
+ display: grid;
+ gap: 18px;
+}
+
+.featureCard,
+.collectionCard {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.featureCard h3,
+.collectionCard h3 {
+ font-family: var(--th-font-display);
+ font-size: 1.4rem;
+ font-weight: 800;
+ letter-spacing: -0.04em;
+ margin: 0;
+}
+
+.sectionHeading {
+ align-items: stretch;
+ display: grid;
+ gap: 14px;
+ grid-template-columns: minmax(0, 1.15fr) minmax(260px, 0.85fr);
+ padding: 16px;
+}
+
+.sectionHeading h2 {
+ font-family: var(--th-font-display);
+ font-size: clamp(1.65rem, 3vw, 2.2rem);
+ font-weight: 800;
+ letter-spacing: -0.04em;
+ margin: 10px 0 0;
+}
+
+.sectionHeadingPrimary,
+.sectionHeadingAside {
+ border: 1px solid var(--th-border);
+ min-width: 0;
+ padding: 18px 20px;
+}
+
+.sectionHeadingPrimary {
+ background: var(--th-panel);
+}
+
+.sectionHeadingAside {
+ align-items: center;
+ background: var(--th-panel-muted);
+ display: flex;
+}
+
+.sectionHeadingAside p {
+ margin: 0;
+}
+
+.collectionCardHeader {
+ align-items: flex-start;
+ display: flex;
+ gap: 12px;
+ justify-content: space-between;
+}
+
+.fieldPill {
+ background: var(--th-panel-muted);
border: 1px solid var(--th-border);
- background: linear-gradient(180deg, #fcfeff 0%, #f3f8ff 100%);
- border-radius: var(--th-radius-lg);
- box-shadow: 0 12px 28px #1345ac1a;
+ color: var(--th-muted);
+ display: inline-flex;
+ font-family: var(--th-font-mono);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.14em;
+ padding: 8px 10px;
+ text-transform: uppercase;
}
-.brand {
- display: flex;
- align-items: center;
- gap: 12px;
+.recordList {
+ display: grid;
+ gap: 18px;
}
-.brandWordmark {
- display: block;
- width: min(320px, 56vw);
- height: auto;
+.recordCardHeader {
+ align-items: flex-start;
}
-.brand h1 {
- font-size: 24px;
- font-family: var(--th-font-display);
- letter-spacing: 0.02em;
- margin: 0;
- color: #0a43d8;
+.recordCardCopy h2 {
+ margin-top: 8px;
}
-.badge {
+.recordMeta {
font-family: var(--th-font-mono);
font-size: 12px;
- color: var(--th-muted);
- padding: 4px 8px;
- border: 1px dashed var(--th-border);
- border-radius: 999px;
- background: #ffffff;
+ line-height: 1.6;
+ margin-top: 10px;
}
-.grid {
+.recordPreviewGrid {
display: grid;
- grid-template-columns: repeat(12, 1fr);
- gap: var(--th-space-md);
- margin-top: var(--th-space-md);
- margin-bottom: var(--th-space-lg);
-}
-
-.card {
- grid-column: span 12;
- border: 1px solid var(--th-border);
- background: linear-gradient(180deg, #fdfefe 0%, #f4f8ff 100%);
- border-radius: var(--th-radius-lg);
- padding: var(--th-space-md);
- box-shadow: 0 8px 24px #1345ac1a;
+ gap: 10px;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ margin-top: 16px;
}
-/* Add clearer vertical rhythm for stacked cards outside grid layouts. */
-.container > .card + .card {
- margin-top: var(--th-space-md);
+.recordPreviewCell {
+ background: var(--th-panel-muted);
+ padding: 14px;
}
-.recordList {
- display: grid;
- gap: var(--th-space-md);
- margin-top: var(--th-space-sm);
+.recordPreviewLabel {
+ color: var(--th-muted);
+ font-family: var(--th-font-mono);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
}
-.recordCard {
- margin: 0;
+.recordPreviewValue {
+ line-height: 1.6;
+ margin-top: 10px;
+ overflow-wrap: anywhere;
}
.recordListSummary {
- margin-top: var(--th-space-md);
- padding: 0 var(--th-space-xs);
+ padding: 0 2px;
}
.recordListActions {
display: flex;
- gap: var(--th-space-sm);
-}
-
-@media (min-width: 820px) {
- .card.half { grid-column: span 6; }
+ flex-wrap: wrap;
+ gap: 8px;
}
-.card h2 {
- margin: 0 0 8px;
- font-size: 24px;
- font-family: var(--th-font-display);
- letter-spacing: 0.01em;
+.muted {
+ color: var(--th-muted);
}
-.displayTitle {
- font-size: 36px;
- line-height: 1.1;
+.row {
+ align-items: flex-start;
+ display: flex;
+ gap: 12px;
+ justify-content: space-between;
}
-.lead {
- font-size: 15px;
- line-height: 1.6;
+.badge {
+ align-items: center;
+ background: var(--th-badge-bg);
+ border: 1px solid var(--th-border);
+ color: var(--th-muted);
+ display: inline-flex;
+ font-family: var(--th-font-mono);
+ font-size: 10px;
+ font-weight: 700;
+ gap: 6px;
+ letter-spacing: 0.12em;
+ max-width: 100%;
+ padding: 6px 10px;
+ text-transform: uppercase;
}
-.muted { color: var(--th-muted); }
-
-.row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
+.controlNote {
+ max-width: 260px;
}
.btn {
- display: inline-flex;
align-items: center;
- justify-content: center;
- gap: 8px;
+ appearance: none;
+ background: var(--th-panel-strong);
border: 1px solid var(--th-border);
- background: #0f56e0;
- color: #ffffff;
- padding: 10px 12px;
- border-radius: var(--th-radius-md);
+ box-shadow: var(--th-shadow);
+ color: var(--th-text);
cursor: pointer;
- transition: transform var(--th-motion-fast) ease, background var(--th-motion-base) ease;
- font-weight: 600;
+ display: inline-flex;
+ font-family: var(--th-font-mono);
+ font-size: 10px;
+ font-weight: 700;
+ gap: 8px;
+ justify-content: center;
+ letter-spacing: 0.18em;
+ min-height: 38px;
+ padding: 10px 14px;
+ text-transform: uppercase;
+ transition:
+ background-color var(--th-motion-base) ease,
+ border-color var(--th-motion-base) ease,
+ box-shadow var(--th-motion-base) ease,
+ color var(--th-motion-base) ease,
+ transform var(--th-motion-fast) ease;
}
.btn:hover {
- background: #0943b8;
+ background: var(--th-panel-muted);
+ border-color: var(--th-primary);
}
.btn:active {
- transform: translateY(1px);
+ box-shadow: none;
+ transform: translate(1px, 1px);
}
.btn.primary {
- border-color: #0f56e0;
- background: linear-gradient(120deg, var(--th-primary), var(--th-primary-strong));
- color: #ffffff;
+ background: var(--th-primary);
+ border-color: var(--th-primary);
+ color: var(--th-primary-foreground);
+}
+
+html[data-theme='dark'] .btn.primary {
+ box-shadow:
+ 0 0 22px hsl(190 100% 50% / 0.22),
+ inset 0 0 0 1px hsl(190 100% 60% / 0.12);
}
.btn.danger {
- border-color: #c52f44;
- background: #c52f44;
- color: #ffffff;
+ background: var(--th-danger);
+ border-color: var(--th-danger);
+ color: hsl(0 0% 100%);
}
-.kv {
- display: grid;
- grid-template-columns: 160px 1fr;
- gap: 8px 12px;
- margin-top: 10px;
+.btn:disabled {
+ box-shadow: none;
+ cursor: not-allowed;
+ opacity: 0.62;
+ transform: none;
+}
+
+.input,
+.select {
+ background: var(--th-input-bg);
+ border: 1px solid var(--th-border);
+ color: var(--th-text);
+ font-family: var(--th-font-body);
+ font-size: 0.96rem;
+ font-weight: 600;
+ padding: 14px 16px;
+ transition:
+ border-color var(--th-motion-base) ease,
+ box-shadow var(--th-motion-base) ease;
+ width: 100%;
+}
+
+.input:focus,
+.select:focus {
+ border-color: var(--th-primary);
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--th-primary) 18%, transparent);
+ outline: none;
}
-.kv div:nth-child(odd) {
+.label {
+ align-items: center;
color: var(--th-muted);
+ display: flex;
font-family: var(--th-font-mono);
- font-size: 12px;
+ font-size: 10px;
+ font-weight: 700;
+ gap: 8px;
+ letter-spacing: 0.18em;
+ margin: 0 0 8px;
+ text-transform: uppercase;
}
-.kv div:nth-child(even) {
- font-family: var(--th-font-mono);
- font-size: 12px;
+.formGrid {
+ display: grid;
+ gap: 16px;
+ margin-top: 16px;
}
-.input, .select {
- width: 100%;
+.fieldGroup {
+ min-width: 0;
+}
+
+.kv {
border: 1px solid var(--th-border);
- border-radius: var(--th-radius-md);
- background: #ffffff;
- color: var(--th-text);
- padding: 10px 12px;
+ display: grid;
+ grid-template-columns: minmax(120px, 180px) minmax(0, 1fr);
+ margin-top: 16px;
}
-.label {
- display: block;
- font-family: var(--th-font-mono);
- font-size: 12px;
+.kv > div {
+ border-bottom: 1px solid var(--th-border);
+ min-width: 0;
+ padding: 14px 16px;
+}
+
+.kv > div:nth-child(odd) {
+ background: var(--th-panel-muted);
+ border-right: 1px solid var(--th-border);
color: var(--th-muted);
- margin: 10px 0 6px;
+ font-family: var(--th-font-mono);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+}
+
+.kv > div:nth-last-child(-n + 2) {
+ border-bottom: 0;
}
.pre {
+ background: var(--th-input-bg);
+ border: 1px solid var(--th-border);
font-family: var(--th-font-mono);
font-size: 12px;
+ margin-top: 12px;
+ padding: 14px 16px;
white-space: pre-wrap;
word-break: break-word;
- background: #ffffff;
- border: 1px solid var(--th-border);
- border-radius: var(--th-radius-md);
- padding: 12px;
}
.networkAlert {
- margin-top: var(--th-space-sm);
- padding: var(--th-space-sm) var(--th-space-md);
- border-radius: var(--th-radius-md);
- background: #fff1f4;
- border: 1px solid #f2adbb;
- display: flex;
- gap: var(--th-space-sm);
align-items: center;
+ background: var(--th-danger-surface);
+ border: 1px solid var(--th-danger-border);
+ color: var(--th-text);
+ display: flex;
+ gap: 14px;
justify-content: space-between;
- font-size: 14px;
+ margin-top: 12px;
+ padding: 16px 18px;
+}
+
+.networkAlertBody {
+ flex: 1;
+ line-height: 1.6;
+}
+
+.networkAlertActions {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
}
.siteFooter {
margin-top: auto;
+ background: var(--th-panel);
border-top: 1px solid var(--th-border);
- padding-top: var(--th-space-md);
+ overflow: hidden;
+ position: relative;
+}
+
+.siteFooter::before {
+ background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--th-primary) 44%, transparent), transparent);
+ content: '';
+ height: 1px;
+ left: 50%;
+ opacity: 0.6;
+ position: absolute;
+ top: 0;
+ transform: translateX(-50%);
+ width: min(50vw, 620px);
+}
+
+.footerGrid {
+ display: grid;
+ gap: 32px 72px;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ margin: 0 auto 40px;
+ max-width: 760px;
+ text-align: left;
+}
+
+.footerSection {
+ min-width: 0;
+}
+
+.footerLabel {
color: var(--th-muted);
- font-size: 13px;
+ font-family: var(--th-font-mono);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.2em;
+ margin: 0 0 14px;
+ text-transform: uppercase;
}
-.footerMeta {
+.footerList {
display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.footerLinkText {
+ font-family: var(--th-font-mono);
+ font-size: 11px;
+ transition: color var(--th-motion-fast) ease;
+}
+
+.footerLinkText:hover,
+.footerLinkText:focus-visible {
+ color: var(--th-primary);
+ outline: none;
+}
+
+.footerMeta {
align-items: center;
- justify-content: center;
- gap: var(--th-space-sm);
+ color: var(--th-muted);
+ display: flex;
flex-wrap: wrap;
+ gap: 10px;
+ justify-content: center;
+ padding: 0;
}
.footerLink {
- display: inline-flex;
align-items: center;
+ display: inline-flex;
+ transition: color var(--th-motion-fast) ease;
+}
+
+.footerLink:hover,
+.footerLink:focus-visible {
+ color: var(--th-primary);
+ outline: none;
}
.txStatus {
- margin-top: var(--th-space-md);
+ background: var(--th-panel-muted);
border: 1px solid var(--th-border);
- background: #ffffff;
- border-radius: var(--th-radius-md);
- padding: var(--th-space-sm) var(--th-space-md);
+ margin-top: 16px;
+ padding: 16px 18px;
}
.txStatus.fail {
- border-color: #f2adbb;
- background: #fff1f4;
+ background: var(--th-danger-surface);
+ border-color: var(--th-danger-border);
}
.txStatusHead {
- font-weight: 600;
- color: var(--th-text);
+ font-weight: 700;
}
.txStatusRow {
- margin-top: var(--th-space-xs);
- display: flex;
align-items: center;
- gap: var(--th-space-sm);
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin-top: 10px;
}
.txStatusLink {
color: var(--th-primary);
- font-weight: 600;
+ font-weight: 700;
+}
+
+@media (min-width: 880px) {
+ .card.half {
+ grid-column: span 6;
+ }
+
+ .heroSplit {
+ grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.75fr);
+ }
+
+ .formGrid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (min-width: 980px) {
+ .featureGrid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 1100px) {
+ .nav {
+ flex-wrap: wrap;
+ gap: 12px 16px;
+ min-height: auto;
+ padding: 12px 16px 14px;
+ }
+
+ .brand {
+ flex: 1 1 260px;
+ }
+
+ .navRail {
+ flex: 1 1 100%;
+ justify-content: flex-start;
+ order: 3;
+ overflow-x: auto;
+ scrollbar-width: none;
+ }
+
+ .navRail::-webkit-scrollbar {
+ display: none;
+ }
+
+ .navCta {
+ display: none;
+ }
+
+ .mainShell {
+ padding-top: 140px;
+ }
+}
+
+@media (max-width: 900px) {
+ .controlCluster {
+ margin-left: auto;
+ }
+
+ .sectionHeading {
+ grid-template-columns: 1fr;
+ }
}
@media (max-width: 820px) {
- .networkAlert {
- flex-direction: column;
- align-items: flex-start;
+ .navShell {
+ position: sticky;
+ }
+
+ .mainShell {
+ padding-top: 18px;
}
- .brand h1 {
- font-size: 20px;
+
+ .siteContent,
+ .siteShell {
+ padding-left: 14px;
+ padding-right: 14px;
}
+
.displayTitle {
- font-size: 30px;
- }
- .brandWordmark {
- width: min(240px, 62vw);
+ font-size: clamp(2rem, 12vw, 3.7rem);
+ max-width: none;
}
+
.row {
+ align-items: stretch;
flex-direction: column;
- align-items: flex-start;
}
+
.recordListSummary {
- align-items: flex-start;
+ align-items: stretch;
}
+
.recordListActions {
width: 100%;
}
+
+ .recordListActions > * {
+ flex: 1;
+ }
+
+ .kv {
+ grid-template-columns: 1fr;
+ }
+
+ .kv > div:nth-child(odd) {
+ border-bottom: 0;
+ border-right: 0;
+ padding-bottom: 6px;
+ }
+
+ .kv > div:nth-child(even) {
+ padding-top: 0;
+ }
+
+ .networkAlert {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .networkAlertActions {
+ align-items: flex-start;
+ }
+
+ .footerGrid {
+ gap: 24px;
+ grid-template-columns: 1fr;
+ margin-bottom: 32px;
+ }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .pageStack > * {
+ animation: panelEnter 420ms ease both;
+ }
+
+ .pageStack > *:nth-child(2) {
+ animation-delay: 50ms;
+ }
+
+ .pageStack > *:nth-child(3) {
+ animation-delay: 100ms;
+ }
+
+ .pageStack > *:nth-child(4) {
+ animation-delay: 150ms;
+ }
+}
+
+@keyframes gridBreathe {
+ 0%,
+ 100% {
+ opacity: 0.2;
+ }
+
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+@keyframes boilSpotOne {
+ 0% {
+ --th-boil-x1: 10%;
+ --th-boil-y1: 20%;
+ }
+
+ 100% {
+ --th-boil-x1: 80%;
+ --th-boil-y1: 40%;
+ }
+}
+
+@keyframes boilSpotTwo {
+ 0% {
+ --th-boil-x2: 90%;
+ --th-boil-y2: 80%;
+ }
+
+ 100% {
+ --th-boil-x2: 20%;
+ --th-boil-y2: 30%;
+ }
+}
+
+@keyframes boilSpotThree {
+ 0% {
+ --th-boil-x3: 40%;
+ --th-boil-y3: 40%;
+ }
+
+ 100% {
+ --th-boil-x3: 60%;
+ --th-boil-y3: 60%;
+ }
+}
+
+@keyframes panelEnter {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .siteGridLayer,
+ .pageStack > * {
+ animation: none !important;
+ }
}
diff --git a/packages/templates/next-export-ui/app/layout.tsx b/packages/templates/next-export-ui/app/layout.tsx
index 074942b..d5ce31c 100644
--- a/packages/templates/next-export-ui/app/layout.tsx
+++ b/packages/templates/next-export-ui/app/layout.tsx
@@ -1,41 +1,113 @@
import './globals.css';
import React from 'react';
+import Link from 'next/link';
import ConnectButton from '../src/components/ConnectButton';
import FaucetButton from '../src/components/FaucetButton';
import FooterDeploymentMeta from '../src/components/FooterDeploymentMeta';
+import LivingGrid from '../src/components/LivingGrid';
import NetworkStatus from '../src/components/NetworkStatus';
+import ThemeToggle from '../src/components/ThemeToggle';
import { ths } from '../src/lib/ths';
-import { rootStyleVars } from '../src/theme';
export const metadata = {
title: `${ths.app.name} - Token Host`,
description: 'Token Host generated app (static export)'
};
+const themeBootScript = `
+(() => {
+ try {
+ const storageKey = 'TH_THEME';
+ const stored = localStorage.getItem(storageKey);
+ const resolved = stored === 'light' || stored === 'dark'
+ ? stored
+ : (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
+ document.documentElement.dataset.theme = resolved;
+ document.documentElement.style.colorScheme = resolved;
+ } catch {
+ document.documentElement.dataset.theme = 'light';
+ document.documentElement.style.colorScheme = 'light';
+ }
+})();
+`;
+
export default function RootLayout(props: { children: React.ReactNode }) {
- const themeVars = rootStyleVars();
+ const primaryCollection = ths.collections[0] ?? null;
+ const navCollections = ths.collections.slice(0, 2);
+
return (
-
-
-
+
+
+
+
-
-
-
-
{ths.app.name}
-
{ths.schemaVersion}
+
diff --git a/packages/templates/next-export-ui/app/page.tsx b/packages/templates/next-export-ui/app/page.tsx
index 18c44a2..ce0f4ec 100644
--- a/packages/templates/next-export-ui/app/page.tsx
+++ b/packages/templates/next-export-ui/app/page.tsx
@@ -1,31 +1,146 @@
import Link from 'next/link';
-import { ths } from '../src/lib/ths';
+import { displayField, hasCreatePayment, mutableFields, ths, transferEnabled } from '../src/lib/ths';
export default function HomePage() {
- return (
-
-
-
Collections
-
- This app is a static export UI that reads /.well-known/tokenhost/manifest.json at runtime.
-
-
+ const firstCollection = ths.collections[0] ?? null;
+ const totalFields = ths.collections.reduce((sum, collection) => sum + collection.fields.length, 0);
+ const editableCollections = ths.collections.filter((collection) => mutableFields(collection).length > 0).length;
+ const transferCollections = ths.collections.filter((collection) => transferEnabled(collection)).length;
+ const paidCollections = ths.collections.filter((collection) => Boolean(hasCreatePayment(collection))).length;
- {ths.collections.map((c) => (
-
-
-
-
{c.name}
-
Fields: {c.fields.length}
+ return (
+
+
+
+
+
+
/tokenhost/launchpad
+
+ static export
+ manifest runtime
+ {ths.app.slug}
+
-
-
List
-
New
+
+ Web3 CRUD
+
+ with a real control-surface aesthetic.
+
+
+ {ths.app.name} ships with a Token Host-branded shell, live chain manifest loading, and generated routes for every schema collection.
+
+
+ {firstCollection ? Open {firstCollection.name} : null}
+ {firstCollection ? Create first record : null}
+
+
/runtime/summary
+
+
+
{ths.collections.length}
+
Collections
+
+
+
{totalFields}
+
Fields
+
+
+
{editableCollections}
+
Editable
+
+
+
{transferCollections}
+
Transferable
+
+
+
+ paid creates: {paidCollections}
+ schema {ths.schemaVersion}
+
+
+
+
+
+
+
+
/manifest
+
Runtime-first deployment
+
+ The generated app reads /.well-known/tokenhost/manifest.json at runtime, so deployment metadata stays outside the bundle.
+
+
+
+
/wallet
+
Public reads, wallet-native writes
+
+ Read-only pages use the deployment chain's public RPC when available, so browsing does not require MetaMask and does not depend on the wallet being on the right network. Create, update, delete, and transfer flows still use the wallet with clean chain and transaction feedback.
+
+
+
+
/hosting
+
Self-hostable release
+
+ The theme, routes, and data surfaces are baked into a static export that can be published anywhere without a custom backend.
+
- ))}
+
+
+
+
+ /collections
+
Generated schema surfaces
+
+
+
Each collection ships with list, create, detail, and transaction-aware routes.
+
+
+
+
+ {ths.collections.map((collection) => {
+ const display = displayField(collection);
+ const fieldPreview = collection.fields.slice(0, 5);
+ const payment = hasCreatePayment(collection);
+
+ return (
+
+
+
+
/{collection.name}
+
{collection.name}
+
+ {collection.fields.length} field{collection.fields.length === 1 ? '' : 's'}
+ {display ? ` · display field ${display.name}` : ''}
+
+
+
{collection.plural || collection.name}
+
+
+
+ {mutableFields(collection).length} mutable
+ create {collection.createRules.access}
+ {transferEnabled(collection) ? 'transfer on' : 'transfer off'}
+ {payment ? paid create : null}
+
+
+
+ {fieldPreview.map((field) => (
+
+ {field.name}
+
+ ))}
+ {collection.fields.length > fieldPreview.length ? +{collection.fields.length - fieldPreview.length} more : null}
+
+
+
+ Browse
+ Create
+
+
+ );
+ })}
+
);
}
diff --git a/packages/templates/next-export-ui/public/static/media/Wordmark-dark.svg b/packages/templates/next-export-ui/public/static/media/Wordmark-dark.svg
new file mode 100644
index 0000000..d797bf9
--- /dev/null
+++ b/packages/templates/next-export-ui/public/static/media/Wordmark-dark.svg
@@ -0,0 +1 @@
+
diff --git a/packages/templates/next-export-ui/src/collection-route/CollectionLayout.tsx b/packages/templates/next-export-ui/src/collection-route/CollectionLayout.tsx
index c05ae49..08d6913 100644
--- a/packages/templates/next-export-ui/src/collection-route/CollectionLayout.tsx
+++ b/packages/templates/next-export-ui/src/collection-route/CollectionLayout.tsx
@@ -1,7 +1,7 @@
import type { ReactNode } from 'react';
import Link from 'next/link';
-import { ths } from '../lib/ths';
+import { displayField, hasCreatePayment, mutableFields, ths, transferEnabled } from '../lib/ths';
export default function CollectionLayout(props: { children: ReactNode; collectionName: string }) {
const collection = ths.collections.find((c) => c.name === props.collectionName);
@@ -18,20 +18,59 @@ export default function CollectionLayout(props: { children: ReactNode; collectio
}
return (
-
-
-
+
+
+
+
/collection/{collection.name}
+
+ {collection.fields.length} fields
+ create {collection.createRules.access}
+ update {collection.updateRules.access}
+ delete {collection.deleteRules.access}
+
+
+
+
-
{collection.name}
-
{collection.fields.length} fields
+
{collection.name}
+
+ Generated routes, chain reads, and transaction flows for the {collection.name} collection.
+
+
+ List records
+ Create record
+
+
+ {collection.fields.slice(0, 6).map((field) => (
+
+ {field.name}
+
+ ))}
+
-
-
List
-
Create
+
+
+
/schema/controls
+
+
+
{mutableFields(collection).length}
+
Mutable fields
+
+
+
{transferEnabled(collection) ? 'ON' : 'OFF'}
+
Transfers
+
+
+
+
+ display {displayField(collection)?.name ?? 'auto'}
+
+ {hasCreatePayment(collection) ? paid create : free create }
+
-
- {props.children}
+
+
{props.children}
);
}
diff --git a/packages/templates/next-export-ui/src/components/ConnectButton.tsx b/packages/templates/next-export-ui/src/components/ConnectButton.tsx
index 8ce7892..fbf5a9e 100644
--- a/packages/templates/next-export-ui/src/components/ConnectButton.tsx
+++ b/packages/templates/next-export-ui/src/components/ConnectButton.tsx
@@ -1,11 +1,11 @@
'use client';
-import React, { useEffect, useMemo, useState } from 'react';
+import React, { useEffect, useState } from 'react';
import { chainFromId } from '../lib/chains';
-import { requestWalletAddress } from '../lib/clients';
+import { chainWithRpcOverride, requestWalletAddress } from '../lib/clients';
import { shortAddress } from '../lib/format';
-import { fetchManifest, getPrimaryDeployment, getTxMode, type TxMode } from '../lib/manifest';
+import { fetchManifest, getPrimaryDeployment, getReadRpcUrl, getTxMode, type TxMode } from '../lib/manifest';
function hasInjectedWallet(): boolean {
return typeof (globalThis as any).ethereum !== 'undefined';
@@ -14,10 +14,14 @@ function hasInjectedWallet(): boolean {
export default function ConnectButton() {
const [account, setAccount] = useState
(null);
const [targetChainId, setTargetChainId] = useState(null);
+ const [targetRpcUrl, setTargetRpcUrl] = useState(null);
const [status, setStatus] = useState(null);
const [txMode, setTxMode] = useState('userPays');
+ const [walletState, setWalletState] = useState<'unknown' | 'present' | 'missing'>('unknown');
- const canConnect = useMemo(() => hasInjectedWallet(), []);
+ useEffect(() => {
+ setWalletState(hasInjectedWallet() ? 'present' : 'missing');
+ }, []);
useEffect(() => {
// Best-effort: hydrate from localStorage.
@@ -41,6 +45,7 @@ export default function ConnectButton() {
const chainId = Number(deployment?.chainId ?? NaN);
if (!cancelled && Number.isFinite(chainId)) {
setTargetChainId(chainId);
+ setTargetRpcUrl(getReadRpcUrl(manifest));
}
} catch {
// ignore best-effort chain hint.
@@ -52,10 +57,13 @@ export default function ConnectButton() {
}, []);
async function connect() {
- if (!canConnect) return;
+ if (walletState !== 'present') return;
try {
setStatus('Connecting wallet...');
- const target = targetChainId && Number.isFinite(targetChainId) ? chainFromId(targetChainId) : null;
+ const target =
+ targetChainId && Number.isFinite(targetChainId)
+ ? chainWithRpcOverride(chainFromId(targetChainId), targetRpcUrl || undefined)
+ : null;
const a = target ? await requestWalletAddress(target) : null;
const accountAddr = a ?? null;
setAccount(accountAddr);
@@ -80,30 +88,30 @@ export default function ConnectButton() {
}
}
- if (!canConnect) {
- return No wallet ;
+ if (walletState === 'unknown') return null;
+
+ if (walletState === 'missing') {
+ return null;
}
if (txMode === 'sponsored') return null;
if (!account) {
return (
-
+
void connect()}>
Connect wallet
- {targetChainId ? target chain {targetChainId} : null}
- {status ? {status} : null}
+ {status ? {status} : null}
);
}
return (
-
+
disconnect()} title={account}>
{shortAddress(account)}
- {targetChainId ? chain {targetChainId} : null}
);
}
diff --git a/packages/templates/next-export-ui/src/components/FaucetButton.tsx b/packages/templates/next-export-ui/src/components/FaucetButton.tsx
index a14c8e4..ac5be3e 100644
--- a/packages/templates/next-export-ui/src/components/FaucetButton.tsx
+++ b/packages/templates/next-export-ui/src/components/FaucetButton.tsx
@@ -1,10 +1,10 @@
'use client';
-import React, { useEffect, useMemo, useRef, useState } from 'react';
+import React, { useEffect, useRef, useState } from 'react';
-import { fetchManifest, getPrimaryDeployment, getTxMode } from '../lib/manifest';
+import { fetchManifest, getPrimaryDeployment, getReadRpcUrl, getTxMode } from '../lib/manifest';
import { chainFromId } from '../lib/chains';
-import { requestWalletAddress } from '../lib/clients';
+import { chainWithRpcOverride, requestWalletAddress } from '../lib/clients';
type FaucetStatus = {
ok: boolean;
@@ -44,15 +44,18 @@ export default function FaucetButton() {
const [busy, setBusy] = useState(false);
const [note, setNote] = useState
(null);
const [reason, setReason] = useState(null);
+ const [hasWallet, setHasWallet] = useState(null);
const targetEthRef = useRef(10);
const noteTimerRef = useRef(null);
- const hasWallet = useMemo(() => typeof (globalThis as any).ethereum !== 'undefined', []);
+ useEffect(() => {
+ setHasWallet(typeof (globalThis as any).ethereum !== 'undefined');
+ }, []);
useEffect(() => {
let cancelled = false;
void (async () => {
- if (!hasWallet) return;
+ if (hasWallet !== true) return;
try {
const manifest = await fetchManifest();
const deployment = getPrimaryDeployment(manifest);
@@ -128,7 +131,7 @@ export default function FaucetButton() {
const chainId = Number(deployment?.chainId ?? NaN);
if (!Number.isFinite(chainId)) throw new Error('Missing chainId in manifest deployment.');
- const chain = chainFromId(chainId);
+ const chain = chainWithRpcOverride(chainFromId(chainId), getReadRpcUrl(manifest) || undefined);
const address = await requestWalletAddress(chain);
const res = await fetch('/__tokenhost/faucet', {
@@ -158,16 +161,16 @@ export default function FaucetButton() {
}
}
- if (!enabled) return null;
+ if (hasWallet === null || !enabled) return null;
return (
-
+
void requestFaucet()} disabled={busy || Boolean(reason)} title="Local faucet (anvil)">
{busy ? 'Funding…' : 'Get test ETH'}
- {reason ?
{reason} : null}
+ {reason ?
{reason} : null}
{note ? (
-
+
{note}
) : null}
diff --git a/packages/templates/next-export-ui/src/components/FooterDeploymentMeta.tsx b/packages/templates/next-export-ui/src/components/FooterDeploymentMeta.tsx
index 172d62f..6bd1c09 100644
--- a/packages/templates/next-export-ui/src/components/FooterDeploymentMeta.tsx
+++ b/packages/templates/next-export-ui/src/components/FooterDeploymentMeta.tsx
@@ -1,6 +1,6 @@
'use client';
-import React, { useEffect, useState } from 'react';
+import React, { type ReactNode, useEffect, useState } from 'react';
import { explorerAddressUrl } from '../lib/chains';
import { fetchManifest, getPrimaryDeployment } from '../lib/manifest';
@@ -10,7 +10,7 @@ function shortAddress(addr: string): string {
return `${addr.slice(0, 8)}…${addr.slice(-6)}`;
}
-export default function FooterDeploymentMeta() {
+export default function FooterDeploymentMeta(props: { children?: ReactNode }) {
const [chainId, setChainId] = useState(null);
const [address, setAddress] = useState(null);
@@ -41,7 +41,10 @@ export default function FooterDeploymentMeta() {
return (
+
/tokenhost/runtime
Powered by Token Host
+ {props.children}
+
public RPC reads
{chainId !== null ?
chain {String(chainId)} : null}
{address ? (
link ? (
diff --git a/packages/templates/next-export-ui/src/components/ImageFieldInput.tsx b/packages/templates/next-export-ui/src/components/ImageFieldInput.tsx
index 90aa376..5c3e2e3 100644
--- a/packages/templates/next-export-ui/src/components/ImageFieldInput.tsx
+++ b/packages/templates/next-export-ui/src/components/ImageFieldInput.tsx
@@ -9,8 +9,9 @@ export default function ImageFieldInput(props: {
value: string;
disabled?: boolean;
onChange: (next: string) => void;
+ onBusyChange?: (busy: boolean) => void;
}) {
- const { manifest, value, disabled, onChange } = props;
+ const { manifest, value, disabled, onChange, onBusyChange } = props;
const config = useMemo(() => getUploadConfig(manifest), [manifest]);
const inputRef = useRef
(null);
const [busy, setBusy] = useState(false);
@@ -40,6 +41,7 @@ export default function ImageFieldInput(props: {
if (localPreviewUrl) URL.revokeObjectURL(localPreviewUrl);
setLocalPreviewUrl(objectUrl);
setBusy(true);
+ onBusyChange?.(true);
setStatus(`Uploading via ${config.runnerMode}…`);
try {
@@ -55,6 +57,7 @@ export default function ImageFieldInput(props: {
setStatus(null);
} finally {
setBusy(false);
+ onBusyChange?.(false);
}
}
diff --git a/packages/templates/next-export-ui/src/components/LivingGrid.tsx b/packages/templates/next-export-ui/src/components/LivingGrid.tsx
new file mode 100644
index 0000000..90ac6d7
--- /dev/null
+++ b/packages/templates/next-export-ui/src/components/LivingGrid.tsx
@@ -0,0 +1,279 @@
+'use client';
+
+import React, { useEffect, useRef } from 'react';
+
+type SmoothEntity = {
+ id: number;
+ x: number;
+ y: number;
+ theta: number;
+ thetaDrift: number;
+ speed: number;
+ life: number;
+ color: string;
+ radius: number;
+ wavePhase: number;
+};
+
+const INACTIVITY_TIMEOUT_MS = 15000;
+
+function resolvePrimaryColor(): string {
+ if (typeof window === 'undefined') return 'hsl(190 100% 50%)';
+ const raw = getComputedStyle(document.documentElement).getPropertyValue('--th-primary').trim();
+ if (!raw) return 'hsl(190 100% 50%)';
+ if (raw.startsWith('#') || raw.startsWith('rgb(') || raw.startsWith('rgba(') || raw.startsWith('hsl(') || raw.startsWith('hsla(')) {
+ return raw;
+ }
+ return `hsl(${raw})`;
+}
+
+export default function LivingGrid() {
+ const canvasRef = useRef(null);
+ const entitiesRef = useRef([]);
+ const mouseRef = useRef({ x: 0, y: 0, accumulator: 0 });
+ const lastTimeRef = useRef(0);
+ const idCounterRef = useRef(0);
+ const lastMouseSpawnAtRef = useRef(0);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const context = canvas.getContext('2d');
+ if (!context) return;
+
+ let animationFrameId = 0;
+ let inactivityTimeoutId = 0;
+ let isAnimating = false;
+ const gridSize = 30;
+
+ function resize() {
+ canvas.width = window.innerWidth;
+ canvas.height = window.innerHeight;
+ }
+
+ function spawnEntity(x: number, y: number) {
+ entitiesRef.current.push({
+ id: idCounterRef.current++,
+ x,
+ y,
+ theta: Math.random() * Math.PI * 2,
+ thetaDrift: (Math.random() - 0.5) * 0.02,
+ speed: 1.2 + Math.random() * 1.8,
+ life: 1,
+ color: resolvePrimaryColor(),
+ radius: 100 + Math.random() * 80,
+ wavePhase: Math.random() * Math.PI * 2
+ });
+ }
+
+ function drawConduit(
+ ctx: CanvasRenderingContext2D,
+ x1: number,
+ y1: number,
+ x2: number,
+ y2: number,
+ gradient: CanvasGradient,
+ alpha: number
+ ) {
+ if (alpha < 0.01) return;
+
+ const primaryColor = resolvePrimaryColor();
+
+ ctx.beginPath();
+ ctx.lineWidth = 20;
+ ctx.strokeStyle = gradient;
+ ctx.globalAlpha = alpha * 0.12;
+ ctx.lineCap = 'butt';
+ ctx.moveTo(x1, y1);
+ ctx.lineTo(x2, y2);
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.lineWidth = 2;
+ ctx.globalAlpha = alpha * 0.85;
+ ctx.moveTo(x1, y1);
+ ctx.lineTo(x2, y2);
+ ctx.stroke();
+
+ ctx.shadowBlur = 4;
+ ctx.shadowColor = primaryColor;
+ ctx.globalAlpha = alpha * 0.4;
+ ctx.stroke();
+ ctx.shadowBlur = 0;
+ ctx.globalAlpha = 1;
+ }
+
+ function updateMouse(event: MouseEvent) {
+ const dx = event.clientX - mouseRef.current.x;
+ const dy = event.clientY - mouseRef.current.y;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+
+ mouseRef.current.x = event.clientX;
+ mouseRef.current.y = event.clientY;
+ mouseRef.current.accumulator += distance;
+
+ if (mouseRef.current.accumulator > 120) {
+ const now = performance.now();
+ if (now - lastMouseSpawnAtRef.current >= 300) {
+ spawnEntity(event.clientX, event.clientY);
+ lastMouseSpawnAtRef.current = now;
+ mouseRef.current.accumulator = 0;
+ }
+ }
+ }
+
+ function stopAnimation() {
+ if (!isAnimating) return;
+ isAnimating = false;
+ if (animationFrameId) {
+ window.cancelAnimationFrame(animationFrameId);
+ animationFrameId = 0;
+ }
+ lastTimeRef.current = 0;
+ }
+
+ function animate(time: number) {
+ if (!lastTimeRef.current) lastTimeRef.current = time;
+ const dt = (time - lastTimeRef.current) / 16.66;
+ lastTimeRef.current = time;
+
+ context.clearRect(0, 0, canvas.width, canvas.height);
+ const entities = entitiesRef.current;
+
+ for (let index = entities.length - 1; index >= 0; index -= 1) {
+ const entity = entities[index];
+ entity.theta += entity.thetaDrift * dt;
+ entity.x += Math.cos(entity.theta) * entity.speed * dt;
+ entity.y += Math.sin(entity.theta) * entity.speed * dt;
+ entity.life -= 0.003 * dt;
+ entity.wavePhase += 0.06 * dt;
+
+ if (
+ entity.life <= 0 ||
+ entity.x < -300 ||
+ entity.x > canvas.width + 300 ||
+ entity.y < -300 ||
+ entity.y > canvas.height + 300
+ ) {
+ entities.splice(index, 1);
+ continue;
+ }
+
+ const minX = Math.floor((entity.x - entity.radius) / gridSize) * gridSize;
+ const maxX = Math.ceil((entity.x + entity.radius) / gridSize) * gridSize;
+ const minY = Math.floor((entity.y - entity.radius) / gridSize) * gridSize;
+ const maxY = Math.ceil((entity.y + entity.radius) / gridSize) * gridSize;
+ const pulse = (Math.sin(entity.wavePhase) + 1) / 2;
+ const globalOpacity = entity.life * (0.4 + pulse * 0.6);
+
+ for (let gx = minX; gx <= maxX; gx += gridSize) {
+ const dx = Math.abs(gx - entity.x);
+ if (dx > entity.radius) continue;
+
+ const horizontalIntensity = Math.pow(1 - dx / entity.radius, 4);
+ const yStart = minY;
+ const yEnd = maxY;
+ const gradient = context.createLinearGradient(gx, yStart, gx, yEnd);
+ const relativeCenter = (entity.y - yStart) / Math.max(1, yEnd - yStart);
+ const gradientWidth = (entity.radius * horizontalIntensity) / Math.max(1, yEnd - yStart);
+
+ gradient.addColorStop(Math.max(0, relativeCenter - gradientWidth), 'transparent');
+ gradient.addColorStop(Math.max(0, Math.min(1, relativeCenter)), entity.color);
+ gradient.addColorStop(Math.min(1, relativeCenter + gradientWidth), 'transparent');
+
+ drawConduit(context, gx, yStart, gx, yEnd, gradient, globalOpacity * horizontalIntensity);
+ }
+
+ for (let gy = minY; gy <= maxY; gy += gridSize) {
+ const dy = Math.abs(gy - entity.y);
+ if (dy > entity.radius) continue;
+
+ const verticalIntensity = Math.pow(1 - dy / entity.radius, 4);
+ const xStart = minX;
+ const xEnd = maxX;
+ const gradient = context.createLinearGradient(xStart, gy, xEnd, gy);
+ const relativeCenter = (entity.x - xStart) / Math.max(1, xEnd - xStart);
+ const gradientWidth = (entity.radius * verticalIntensity) / Math.max(1, xEnd - xStart);
+
+ gradient.addColorStop(Math.max(0, relativeCenter - gradientWidth), 'transparent');
+ gradient.addColorStop(Math.max(0, Math.min(1, relativeCenter)), entity.color);
+ gradient.addColorStop(Math.min(1, relativeCenter + gradientWidth), 'transparent');
+
+ drawConduit(context, xStart, gy, xEnd, gy, gradient, globalOpacity * verticalIntensity);
+ }
+ }
+
+ if (Math.random() < 0.015) {
+ spawnEntity(Math.random() * canvas.width, Math.random() * canvas.height);
+ }
+
+ animationFrameId = window.requestAnimationFrame(animate);
+ }
+
+ function startAnimation() {
+ if (isAnimating) return;
+ if (document.visibilityState !== 'visible') return;
+ isAnimating = true;
+ animationFrameId = window.requestAnimationFrame(animate);
+ }
+
+ function scheduleInactivityPause() {
+ if (inactivityTimeoutId) window.clearTimeout(inactivityTimeoutId);
+ inactivityTimeoutId = window.setTimeout(() => {
+ stopAnimation();
+ }, INACTIVITY_TIMEOUT_MS);
+ }
+
+ function registerActivity() {
+ if (document.visibilityState !== 'visible') return;
+ scheduleInactivityPause();
+ startAnimation();
+ }
+
+ function handleMouseMove(event: MouseEvent) {
+ updateMouse(event);
+ registerActivity();
+ }
+
+ function handleGenericActivity() {
+ registerActivity();
+ }
+
+ function handleVisibilityChange() {
+ if (document.visibilityState === 'visible') {
+ registerActivity();
+ return;
+ }
+ stopAnimation();
+ }
+
+ window.addEventListener('resize', resize);
+ window.addEventListener('mousemove', handleMouseMove);
+ window.addEventListener('mousedown', handleGenericActivity);
+ window.addEventListener('wheel', handleGenericActivity, { passive: true });
+ window.addEventListener('touchstart', handleGenericActivity, { passive: true });
+ window.addEventListener('keydown', handleGenericActivity);
+ window.addEventListener('focus', handleGenericActivity);
+ window.addEventListener('blur', stopAnimation);
+ document.addEventListener('visibilitychange', handleVisibilityChange);
+ resize();
+ registerActivity();
+
+ return () => {
+ window.removeEventListener('resize', resize);
+ window.removeEventListener('mousemove', handleMouseMove);
+ window.removeEventListener('mousedown', handleGenericActivity);
+ window.removeEventListener('wheel', handleGenericActivity);
+ window.removeEventListener('touchstart', handleGenericActivity);
+ window.removeEventListener('keydown', handleGenericActivity);
+ window.removeEventListener('focus', handleGenericActivity);
+ window.removeEventListener('blur', stopAnimation);
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
+ if (inactivityTimeoutId) window.clearTimeout(inactivityTimeoutId);
+ stopAnimation();
+ };
+ }, []);
+
+ return ;
+}
diff --git a/packages/templates/next-export-ui/src/components/NetworkStatus.tsx b/packages/templates/next-export-ui/src/components/NetworkStatus.tsx
index 8d3473f..03b360c 100644
--- a/packages/templates/next-export-ui/src/components/NetworkStatus.tsx
+++ b/packages/templates/next-export-ui/src/components/NetworkStatus.tsx
@@ -1,24 +1,29 @@
'use client';
-import React, { useEffect, useMemo, useState } from 'react';
+import React, { useEffect, useState } from 'react';
import { chainFromId } from '../lib/chains';
-import { ensureWalletChain } from '../lib/clients';
-import { fetchManifest, getPrimaryDeployment, getTxMode, type TxMode } from '../lib/manifest';
+import { chainWithRpcOverride, ensureWalletChain } from '../lib/clients';
+import { fetchManifest, getPrimaryDeployment, getReadRpcUrl, getTxMode, type TxMode } from '../lib/manifest';
export default function NetworkStatus() {
- const hasWallet = useMemo(() => typeof (globalThis as any).ethereum !== 'undefined', []);
+ const [hasWallet, setHasWallet] = useState(null);
const [targetChainId, setTargetChainId] = useState(null);
+ const [targetRpcUrl, setTargetRpcUrl] = useState(null);
const [walletChainId, setWalletChainId] = useState(null);
const [busy, setBusy] = useState(false);
const [note, setNote] = useState(null);
const [txMode, setTxMode] = useState('userPays');
+ useEffect(() => {
+ setHasWallet(typeof (globalThis as any).ethereum !== 'undefined');
+ }, []);
+
useEffect(() => {
let cancelled = false;
async function refresh() {
- if (!hasWallet) return;
+ if (hasWallet !== true) return;
try {
const manifest = await fetchManifest();
const mode = getTxMode(manifest);
@@ -27,11 +32,13 @@ export default function NetworkStatus() {
const deployment = getPrimaryDeployment(manifest);
const target = Number(deployment?.chainId ?? NaN);
if (!Number.isFinite(target)) return;
+ const rpcUrl = getReadRpcUrl(manifest);
const eth = (globalThis as any).ethereum as any;
const current = await eth.request({ method: 'eth_chainId' });
const parsed = Number.parseInt(String(current), 16);
if (!cancelled) {
setTargetChainId(target);
+ setTargetRpcUrl(rpcUrl);
setWalletChainId(Number.isFinite(parsed) ? parsed : null);
}
} catch {
@@ -57,7 +64,7 @@ export default function NetworkStatus() {
setBusy(true);
setNote(null);
try {
- await ensureWalletChain(chainFromId(targetChainId));
+ await ensureWalletChain(chainWithRpcOverride(chainFromId(targetChainId), targetRpcUrl || undefined));
setWalletChainId(targetChainId);
setNote('Wallet switched to expected network.');
} catch (e: any) {
@@ -67,15 +74,15 @@ export default function NetworkStatus() {
}
}
- if (txMode === 'sponsored') return null;
+ if (hasWallet === null || txMode === 'sponsored') return null;
if (!hasWallet || !targetChainId || walletChainId === null || walletChainId === targetChainId) return null;
return (
-
+
Wrong network: wallet on chainId {walletChainId}, app deployment on chainId {targetChainId}.
-
+
void fixNetwork()}>
{busy ? 'Switching…' : 'Switch network'}
diff --git a/packages/templates/next-export-ui/src/components/ThemeToggle.tsx b/packages/templates/next-export-ui/src/components/ThemeToggle.tsx
new file mode 100644
index 0000000..6d0a821
--- /dev/null
+++ b/packages/templates/next-export-ui/src/components/ThemeToggle.tsx
@@ -0,0 +1,114 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+
+type ThemeMode = 'light' | 'dark';
+
+const STORAGE_KEY = 'TH_THEME';
+
+function resolveTheme(): ThemeMode {
+ if (typeof window === 'undefined') return 'light';
+ try {
+ const stored = window.localStorage.getItem(STORAGE_KEY);
+ if (stored === 'light' || stored === 'dark') return stored;
+ } catch {
+ // ignore localStorage access failures and fall back to system theme
+ }
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+}
+
+function applyTheme(theme: ThemeMode) {
+ const root = document.documentElement;
+ root.dataset.theme = theme;
+ root.style.colorScheme = theme;
+}
+
+function SunIcon() {
+ return (
+
+
+
+
+ );
+}
+
+function MoonIcon() {
+ return (
+
+
+
+ );
+}
+
+export default function ThemeToggle() {
+ const [theme, setTheme] = useState
(null);
+
+ useEffect(() => {
+ const next = resolveTheme();
+ setTheme(next);
+ applyTheme(next);
+
+ const media = window.matchMedia('(prefers-color-scheme: dark)');
+ const onChange = () => {
+ try {
+ const stored = window.localStorage.getItem(STORAGE_KEY);
+ if (stored === 'light' || stored === 'dark') return;
+ } catch {
+ // ignore localStorage access failures and follow system theme
+ }
+ const resolved = media.matches ? 'dark' : 'light';
+ setTheme(resolved);
+ applyTheme(resolved);
+ };
+
+ if (typeof media.addEventListener === 'function') {
+ media.addEventListener('change', onChange);
+ return () => media.removeEventListener('change', onChange);
+ }
+
+ media.addListener(onChange);
+ return () => media.removeListener(onChange);
+ }, []);
+
+ function toggleTheme() {
+ const next: ThemeMode = theme === 'dark' ? 'light' : 'dark';
+ setTheme(next);
+ applyTheme(next);
+ try {
+ window.localStorage.setItem(STORAGE_KEY, next);
+ } catch {
+ // ignore localStorage write failures
+ }
+ }
+
+ return (
+ toggleTheme()}
+ aria-label="Toggle theme"
+ title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
+ >
+
+
+
+
+
+
+ Toggle theme
+
+ );
+}
diff --git a/packages/templates/next-export-ui/src/lib/chains.ts b/packages/templates/next-export-ui/src/lib/chains.ts
index 6a8689e..d09c2cf 100644
--- a/packages/templates/next-export-ui/src/lib/chains.ts
+++ b/packages/templates/next-export-ui/src/lib/chains.ts
@@ -3,11 +3,46 @@ import type { Chain } from 'viem';
const FILECOIN_CALIBRATION_CHAIN_ID = 314159;
const FILECOIN_MAINNET_CHAIN_ID = 314;
+const filecoinCalibration = {
+ id: FILECOIN_CALIBRATION_CHAIN_ID,
+ name: 'Filecoin Calibration',
+ nativeCurrency: { name: 'tFIL', symbol: 'tFIL', decimals: 18 },
+ rpcUrls: {
+ default: {
+ http: ['https://api.calibration.node.glif.io/rpc/v1']
+ }
+ },
+ blockExplorers: {
+ default: {
+ name: 'Filfox',
+ url: 'https://calibration.filfox.info/en'
+ }
+ }
+} as const satisfies Chain;
+
+const filecoinMainnet = {
+ id: FILECOIN_MAINNET_CHAIN_ID,
+ name: 'Filecoin',
+ nativeCurrency: { name: 'FIL', symbol: 'FIL', decimals: 18 },
+ rpcUrls: {
+ default: {
+ http: ['https://api.node.glif.io']
+ }
+ },
+ blockExplorers: {
+ default: {
+ name: 'Filfox',
+ url: 'https://filfox.info/en'
+ }
+ }
+} as const satisfies Chain;
export function chainFromId(chainId: number): Chain {
if (chainId === anvil.id) return anvil;
if (chainId === sepolia.id) return sepolia;
if (chainId === mainnet.id) return mainnet;
+ if (chainId === FILECOIN_CALIBRATION_CHAIN_ID) return filecoinCalibration;
+ if (chainId === FILECOIN_MAINNET_CHAIN_ID) return filecoinMainnet;
// Minimal fallback for unknown chains.
return {
diff --git a/packages/templates/next-export-ui/src/lib/clients.ts b/packages/templates/next-export-ui/src/lib/clients.ts
index d5c2e47..d03ff24 100644
--- a/packages/templates/next-export-ui/src/lib/clients.ts
+++ b/packages/templates/next-export-ui/src/lib/clients.ts
@@ -1,9 +1,27 @@
import { createPublicClient, createWalletClient, custom, http } from 'viem';
import type { Chain } from 'viem';
+export function chainWithRpcOverride(chain: Chain, rpcUrl?: string): Chain {
+ if (!rpcUrl) return chain;
+ return {
+ ...chain,
+ rpcUrls: {
+ ...chain.rpcUrls,
+ default: {
+ ...(chain.rpcUrls?.default ?? {}),
+ http: [rpcUrl]
+ },
+ public: {
+ ...(chain.rpcUrls?.public ?? chain.rpcUrls?.default ?? {}),
+ http: [rpcUrl]
+ }
+ }
+ } as Chain;
+}
+
export function resolveRpcUrl(chain: Chain, override?: string): string | null {
- if (override) return override;
- const httpUrls = chain.rpcUrls?.default?.http;
+ const resolvedChain = chainWithRpcOverride(chain, override);
+ const httpUrls = resolvedChain.rpcUrls?.default?.http;
if (Array.isArray(httpUrls) && httpUrls.length > 0) return httpUrls[0] ?? null;
return null;
}
@@ -11,10 +29,11 @@ export function resolveRpcUrl(chain: Chain, override?: string): string | null {
export function makePublicClient(chain: Chain, rpcUrl?: string): any {
// Prefer explicit HTTP RPC for reads so chain mismatch in the user's wallet
// doesn't silently read from the wrong network.
- const url = resolveRpcUrl(chain, rpcUrl);
+ const resolvedChain = chainWithRpcOverride(chain, rpcUrl);
+ const url = resolveRpcUrl(resolvedChain);
if (url) {
return createPublicClient({
- chain,
+ chain: resolvedChain,
transport: http(url)
});
}
@@ -23,7 +42,7 @@ export function makePublicClient(chain: Chain, rpcUrl?: string): any {
const eth = (globalThis as any).ethereum as any;
if (eth) {
return createPublicClient({
- chain,
+ chain: resolvedChain,
transport: custom(eth)
});
}
@@ -41,6 +60,47 @@ export function makeWalletClient(chain: Chain): any {
});
}
+export function makeInjectedPublicClient(chain: Chain): any {
+ const eth = (globalThis as any).ethereum as any;
+ if (!eth) throw new Error('No injected wallet found (window.ethereum).');
+
+ return createPublicClient({
+ chain,
+ transport: custom(eth)
+ });
+}
+
+function getInjectedProvider(): any {
+ const eth = (globalThis as any).ethereum as any;
+ if (!eth) throw new Error('No injected wallet found (window.ethereum).');
+ return eth;
+}
+
+function toHexChainId(chainId: number): `0x${string}` {
+ return `0x${chainId.toString(16)}`;
+}
+
+function isLocalRpcUrl(rpcUrl: string | null): boolean {
+ if (!rpcUrl) return false;
+ return /^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?/i.test(rpcUrl);
+}
+
+function buildAddEthereumChainParams(chain: Chain): any {
+ const rpcUrl = resolveRpcUrl(chain);
+ return {
+ chainId: toHexChainId(chain.id),
+ chainName: chain.name,
+ rpcUrls: rpcUrl ? [rpcUrl] : [],
+ nativeCurrency: chain.nativeCurrency,
+ blockExplorerUrls: chain.blockExplorers?.default?.url ? [chain.blockExplorers.default.url] : undefined
+ };
+}
+
+async function requestProvider(method: string, params?: any[]): Promise {
+ const eth = getInjectedProvider();
+ return await eth.request(params ? { method, params } : { method });
+}
+
function extractErrorCode(e: any): string | number | null {
const codes = [
e?.code,
@@ -82,16 +142,73 @@ function isUserRejected(e: any): boolean {
return String(code) === '4001' || /user rejected|rejected the request/i.test(msg);
}
+async function refreshWalletChainConfig(chain: Chain, currentChainId: number): Promise {
+ const rpcUrl = resolveRpcUrl(chain);
+ if (!rpcUrl) return;
+
+ try {
+ await requestProvider('wallet_addEthereumChain', [buildAddEthereumChainParams(chain)]);
+ } catch (e: any) {
+ if (isUserRejected(e)) {
+ throw new Error(
+ `Wallet network entry for chainId ${chain.id} may still point at a stale RPC URL. ` +
+ `Your wallet is currently using chainId ${currentChainId}. Please approve the network update or manually set the RPC URL to ${rpcUrl}.`
+ );
+ }
+ // Some wallets reject duplicate addChain requests or do not support in-place updates.
+ // In that case we keep going and rely on the current network config.
+ }
+
+ try {
+ await requestProvider('wallet_switchEthereumChain', [{ chainId: toHexChainId(chain.id) }]);
+ } catch (e: any) {
+ if (isUserRejected(e)) {
+ throw new Error(
+ `Wallet network entry for chainId ${chain.id} may still point at a stale RPC URL. ` +
+ `Your wallet is currently using chainId ${currentChainId}. Please approve the network update or manually set the RPC URL to ${rpcUrl}.`
+ );
+ }
+ }
+}
+
+async function assertWalletTracksTargetLocalRpc(chain: Chain): Promise {
+ const rpcUrl = resolveRpcUrl(chain);
+ if (!isLocalRpcUrl(rpcUrl)) return;
+
+ const appReadClient = makePublicClient(chain, rpcUrl || undefined);
+ const walletReadClient = makeInjectedPublicClient(chain);
+
+ let appBlockHash = '0x';
+ const appBlock = await appReadClient.getBlock({ blockTag: 'latest' });
+ appBlockHash = String(appBlock?.hash ?? '0x').toLowerCase();
+
+ for (const delayMs of [0, 250, 750, 1500, 2500]) {
+ if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs));
+ const walletBlock = await walletReadClient.getBlock({ blockTag: 'latest' });
+ const walletBlockHash = String(walletBlock?.hash ?? '0x').toLowerCase();
+ if (walletBlockHash && walletBlockHash !== '0x' && walletBlockHash === appBlockHash) return;
+ }
+
+ throw new Error(
+ `Wallet network entry for chainId ${chain.id} is still not using the same local RPC as this app. ` +
+ `Expected RPC: ${rpcUrl}. Please update the wallet network RPC and retry.`
+ );
+}
+
export async function ensureWalletChain(chain: Chain): Promise {
const wallet = makeWalletClient(chain);
const currentChainId = await wallet.getChainId();
- if (currentChainId === chain.id) return;
-
const rpcUrl = resolveRpcUrl(chain);
const manualHint = rpcUrl
? `In MetaMask, add/switch to "${chain.name}" (chainId ${chain.id}) with RPC URL ${rpcUrl}.`
: `Switch networks in your wallet to chainId ${chain.id}.`;
+ if (currentChainId === chain.id) {
+ await refreshWalletChainConfig(chain, currentChainId);
+ await assertWalletTracksTargetLocalRpc(chain);
+ return;
+ }
+
try {
await wallet.switchChain({ id: chain.id });
} catch (e1: any) {
@@ -105,7 +222,7 @@ export async function ensureWalletChain(chain: Chain): Promise {
// Many wallets don't reliably surface the "unknown chain" code/message.
// Try add+switch as a best-effort fallback.
try {
- await wallet.addChain({ chain });
+ await refreshWalletChainConfig(chain, currentChainId);
} catch (eAdd: any) {
if (isUserRejected(eAdd)) {
throw new Error(
@@ -125,6 +242,8 @@ export async function ensureWalletChain(chain: Chain): Promise {
);
}
}
+
+ await assertWalletTracksTargetLocalRpc(chain);
}
export async function requestWalletAddress(chain: Chain): Promise<`0x${string}`> {
diff --git a/packages/templates/next-export-ui/src/lib/manifest.ts b/packages/templates/next-export-ui/src/lib/manifest.ts
index baa849e..c03c64d 100644
--- a/packages/templates/next-export-ui/src/lib/manifest.ts
+++ b/packages/templates/next-export-ui/src/lib/manifest.ts
@@ -33,6 +33,12 @@ export function getPrimaryDeployment(manifest: any): any {
return primary ?? deployments[0] ?? null;
}
+export function getReadRpcUrl(manifest: any): string | null {
+ const configured = String(manifest?.extensions?.localPreview?.rpcUrl ?? '').trim();
+ if (configured) return configured;
+ return null;
+}
+
export function getTxMode(manifest: any): TxMode {
const mode = String(manifest?.extensions?.tx?.mode ?? '').trim();
if (mode === 'sponsored') return 'sponsored';
@@ -75,3 +81,9 @@ export function getUploadRunnerMode(manifest: any): UploadRunnerMode {
if (mode === 'foc-process') return 'foc-process';
return 'local';
}
+
+export function getListMaxLimit(manifest: any): number {
+ const configured = Number(manifest?.extensions?.chainLimits?.lists?.maxLimit ?? NaN);
+ if (Number.isFinite(configured) && configured >= 1) return Math.floor(configured);
+ return 50;
+}
diff --git a/packages/templates/next-export-ui/src/lib/runtime.ts b/packages/templates/next-export-ui/src/lib/runtime.ts
index 4019751..6575b14 100644
--- a/packages/templates/next-export-ui/src/lib/runtime.ts
+++ b/packages/templates/next-export-ui/src/lib/runtime.ts
@@ -2,8 +2,8 @@ import { fetchAppAbi } from './abi';
import { listRecords, listRecordsByIndex } from './app';
import { extractHashtagTokens, hashtagIndexKey, normalizeHashtagToken } from './indexing';
import { chainFromId } from './chains';
-import { makePublicClient } from './clients';
-import { fetchManifest, getPrimaryDeployment } from './manifest';
+import { chainWithRpcOverride, makePublicClient } from './clients';
+import { fetchManifest, getListMaxLimit, getPrimaryDeployment, getReadRpcUrl } from './manifest';
export type AppRuntime = {
manifest: any;
@@ -15,7 +15,11 @@ export type AppRuntime = {
};
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
-const MAX_LIST_PAGE_SIZE = 50;
+
+function clampListPageSize(manifest: any, requested?: number): number {
+ const maxListPageSize = getListMaxLimit(manifest);
+ return Math.min(maxListPageSize, Math.max(1, Number(requested ?? maxListPageSize)));
+}
export async function loadAppRuntime(rpcOverride?: string): Promise {
const manifest = await fetchManifest();
@@ -32,8 +36,9 @@ export async function loadAppRuntime(rpcOverride?: string): Promise
throw new Error('App is not deployed yet (manifest has 0x0 address).');
}
- const chain = chainFromId(chainId);
- const publicClient = makePublicClient(chain, rpcOverride);
+ const resolvedRpcUrl = rpcOverride || getReadRpcUrl(manifest) || undefined;
+ const chain = chainWithRpcOverride(chainFromId(chainId), resolvedRpcUrl);
+ const publicClient = makePublicClient(chain, resolvedRpcUrl);
const abi = await fetchAppAbi();
return {
@@ -51,9 +56,10 @@ export async function listAllRecords(args: {
abi: any[];
address: `0x${string}`;
collectionName: string;
+ manifest?: any;
pageSize?: number;
}): Promise<{ ids: bigint[]; records: any[] }> {
- const pageSize = Math.min(MAX_LIST_PAGE_SIZE, Math.max(1, Number(args.pageSize ?? MAX_LIST_PAGE_SIZE)));
+ const pageSize = clampListPageSize(args.manifest, args.pageSize);
const ids: bigint[] = [];
const records: any[] = [];
let cursor = 0n;
@@ -88,6 +94,7 @@ export async function listRecordsByFieldValue(args: {
collectionName: string;
fieldName: string;
value: unknown;
+ manifest?: any;
pageSize?: number;
}): Promise<{ ids: bigint[]; records: any[] }> {
const page = await listAllRecords(args);
@@ -112,11 +119,12 @@ export async function listAllRecordsByIndex(args: {
collectionName: string;
fieldName: string;
key: `0x${string}`;
+ manifest?: any;
pageSize?: number;
includeDeleted?: boolean;
recordMatches?: (record: any) => boolean;
}): Promise<{ ids: bigint[]; records: any[] }> {
- const pageSize = Math.min(MAX_LIST_PAGE_SIZE, Math.max(1, Number(args.pageSize ?? MAX_LIST_PAGE_SIZE)));
+ const pageSize = clampListPageSize(args.manifest, args.pageSize);
const ids: bigint[] = [];
const records: any[] = [];
const seenIds = new Set();
@@ -164,6 +172,7 @@ export async function listHashtagRecords(args: {
collectionName: string;
fieldName: string;
hashtag: string;
+ manifest?: any;
pageSize?: number;
}): Promise<{ hashtag: string; ids: bigint[]; records: any[] }> {
const normalized = normalizeHashtagToken(args.hashtag);
@@ -178,6 +187,7 @@ export async function listHashtagRecords(args: {
collectionName: args.collectionName,
fieldName: args.fieldName,
key: hashtagIndexKey(normalized),
+ manifest: args.manifest,
pageSize: args.pageSize,
includeDeleted: true,
recordMatches: (record) => extractHashtagTokens(String(record?.[args.fieldName] ?? '')).includes(normalized)
@@ -197,6 +207,7 @@ export async function findRecordByFieldValue(args: {
collectionName: string;
fieldName: string;
value: unknown;
+ manifest?: any;
pageSize?: number;
}): Promise<{ id: bigint | null; record: any | null }> {
const page = await listRecordsByFieldValue(args);
diff --git a/packages/templates/next-export-ui/src/lib/tx.ts b/packages/templates/next-export-ui/src/lib/tx.ts
index 82499f7..fb594c4 100644
--- a/packages/templates/next-export-ui/src/lib/tx.ts
+++ b/packages/templates/next-export-ui/src/lib/tx.ts
@@ -1,6 +1,6 @@
import { encodeFunctionData, type Address } from 'viem';
-import { makeWalletClient, requestWalletAddress } from './clients';
+import { makeInjectedPublicClient, makeWalletClient, requestWalletAddress, resolveRpcUrl } from './clients';
import { getRelayBaseUrl, getTxMode } from './manifest';
import type { TxPhase } from '../components/TxStatus';
@@ -9,6 +9,77 @@ export type SubmitWriteTxResult = {
receipt: any;
};
+async function estimateWriteGas(args: {
+ publicClient: any;
+ address: `0x${string}`;
+ abi: any[];
+ functionName: string;
+ contractArgs: any[];
+ account: `0x${string}`;
+ value?: bigint;
+}): Promise {
+ try {
+ const estimated = (await args.publicClient.estimateContractGas({
+ address: args.address,
+ abi: args.abi,
+ functionName: args.functionName,
+ args: args.contractArgs,
+ account: args.account,
+ value: args.value
+ })) as bigint;
+
+ const latestBlock = await args.publicClient.getBlock({ blockTag: 'latest' });
+ const blockGasLimit = BigInt(latestBlock?.gasLimit ?? 0n);
+ if (blockGasLimit > 0n) {
+ const maxSafeGas = blockGasLimit > 1n ? blockGasLimit - 1n : blockGasLimit;
+ return estimated > maxSafeGas ? maxSafeGas : estimated;
+ }
+
+ return estimated;
+ } catch {
+ return undefined;
+ }
+}
+
+function waitMs(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+async function assertWalletRpcMatchesDeployment(args: {
+ chain: any;
+ publicClient: any;
+ address: `0x${string}`;
+}): Promise {
+ const walletReadClient = makeInjectedPublicClient(args.chain);
+ const expectedCode = await args.publicClient.getCode({ address: args.address });
+ const expected = String(expectedCode ?? '0x').toLowerCase();
+ let wallet = '0x';
+
+ for (const delayMs of [0, 350, 1000, 2000]) {
+ if (delayMs > 0) await waitMs(delayMs);
+ const walletCode = await walletReadClient.getCode({ address: args.address });
+ wallet = String(walletCode ?? '0x').toLowerCase();
+ if (expected === wallet) return;
+ }
+
+ const expectedRpc = resolveRpcUrl(args.chain);
+ if (wallet === '0x') {
+ throw new Error(
+ `Wallet RPC does not see a contract at ${args.address}. ` +
+ `Your wallet is not pointed at the same deployment as this app. ` +
+ `${expectedRpc ? `Expected RPC: ${expectedRpc}. ` : ''}` +
+ `Please update the wallet network RPC and retry.`
+ );
+ }
+
+ throw new Error(
+ `Wallet RPC is not pointed at the same deployment as this app. ` +
+ `The contract at ${args.address} differs between the app read RPC and the wallet RPC. ` +
+ `${expectedRpc ? `Expected RPC: ${expectedRpc}. ` : ''}` +
+ `Please update the wallet network RPC and retry.`
+ );
+}
+
export async function submitWriteTx(args: {
manifest: any;
deployment: any;
@@ -56,6 +127,13 @@ export async function submitWriteTx(args: {
args.onHash?.(hash);
args.onPhase?.('submitted');
args.setStatus?.(`Submitted ${hash.slice(0, 10)}…`);
+ const relayReceipt = body.receipt ?? null;
+ if (relayReceipt) {
+ args.onPhase?.('confirmed');
+ args.setStatus?.(`Confirmed ${hash.slice(0, 10)}…`);
+ return { hash, receipt: relayReceipt };
+ }
+
args.onPhase?.('confirming');
args.setStatus?.('Waiting for confirmation…');
const receipt = await args.publicClient.waitForTransactionReceipt({ hash });
@@ -67,7 +145,23 @@ export async function submitWriteTx(args: {
args.onPhase?.('submitting');
args.setStatus?.('Connecting wallet…');
const account = await requestWalletAddress(args.chain);
+ args.setStatus?.('Verifying wallet RPC…');
+ await assertWalletRpcMatchesDeployment({
+ chain: args.chain,
+ publicClient: args.publicClient,
+ address: args.address
+ });
const walletClient = makeWalletClient(args.chain);
+ args.setStatus?.('Estimating gas…');
+ const gas = await estimateWriteGas({
+ publicClient: args.publicClient,
+ address: args.address,
+ abi: args.abi,
+ functionName: args.functionName,
+ contractArgs: args.contractArgs,
+ account,
+ value: args.value
+ });
args.setStatus?.('Sending transaction…');
const hash = (await walletClient.writeContract({
address: args.address as Address,
@@ -75,6 +169,7 @@ export async function submitWriteTx(args: {
functionName: args.functionName,
args: args.contractArgs,
account,
+ gas,
value: args.value,
chain: args.chain
})) as `0x${string}`;
diff --git a/packages/templates/next-export-ui/src/lib/upload.ts b/packages/templates/next-export-ui/src/lib/upload.ts
index 3e189d9..1b9e716 100644
--- a/packages/templates/next-export-ui/src/lib/upload.ts
+++ b/packages/templates/next-export-ui/src/lib/upload.ts
@@ -22,6 +22,47 @@ export type UploadConfig = {
maxBytes: number | null;
};
+type PendingUploadResponse = {
+ ok: true;
+ pending: true;
+ jobId: string;
+ statusUrl?: string;
+};
+
+async function buildUploadNetworkError(config: UploadConfig, xhr: XMLHttpRequest): Promise {
+ const parts = [
+ `Upload request failed before the server returned a usable response.`,
+ `Endpoint: ${config.endpointUrl}.`
+ ];
+
+ if (xhr.status) {
+ parts.push(`HTTP ${xhr.status}${xhr.statusText ? ` ${xhr.statusText}` : ''}.`);
+ }
+
+ try {
+ const res = await fetch(config.statusUrl, { cache: 'no-store' });
+ const body = await res.json().catch(() => null);
+ if (body && typeof body === 'object') {
+ const enabled = body.enabled === true ? 'enabled' : 'disabled';
+ const provider = body.provider ? `provider=${String(body.provider)}` : null;
+ const runner = body.runnerMode ? `runner=${String(body.runnerMode)}` : null;
+ const reason = body.reason ? `reason=${String(body.reason)}` : null;
+ const statusBits = [enabled, provider, runner, reason].filter(Boolean);
+ if (statusBits.length > 0) {
+ parts.push(`Upload status: ${statusBits.join(', ')}.`);
+ }
+ if (body.lastError) {
+ const when = body.lastErrorAt ? ` (${String(body.lastErrorAt)})` : '';
+ parts.push(`Last server error${when}: ${String(body.lastError)}.`);
+ }
+ }
+ } catch {
+ // ignore status enrichment failures
+ }
+
+ return parts.join(' ');
+}
+
function normalizeUrl(value: string, fallback: string): string {
const trimmed = String(value || '').trim();
if (!trimmed) return fallback;
@@ -74,42 +115,101 @@ export async function uploadFile(args: {
}
return await new Promise((resolve, reject) => {
+ let settled = false;
+ const finishResolve = (value: UploadResult) => {
+ if (settled) return;
+ settled = true;
+ resolve(value);
+ };
+ const finishReject = (error: Error) => {
+ if (settled) return;
+ settled = true;
+ reject(error);
+ };
const xhr = new XMLHttpRequest();
xhr.open('POST', config.endpointUrl, true);
xhr.responseType = 'text';
+ xhr.timeout = 5 * 60 * 1000;
xhr.setRequestHeader('Content-Type', args.file.type || 'application/octet-stream');
xhr.setRequestHeader('X-TokenHost-Upload-Filename', args.file.name || 'upload.bin');
xhr.setRequestHeader('X-TokenHost-Upload-Size', String(args.file.size));
+ if (config.runnerMode === 'foc-process') {
+ xhr.setRequestHeader('X-TokenHost-Upload-Mode', 'async');
+ }
xhr.upload.onprogress = (event) => {
if (!event.lengthComputable || !args.onProgress) return;
args.onProgress(Math.max(0, Math.min(100, Math.round((event.loaded / event.total) * 100))));
};
- xhr.onerror = () => reject(new Error('Upload request failed.'));
- xhr.onabort = () => reject(new Error('Upload request was aborted.'));
- xhr.onload = () => {
- let body: any = null;
- try {
- body = xhr.responseText ? JSON.parse(xhr.responseText) : null;
- } catch {
- body = null;
+ async function pollUploadJob(statusUrl: string): Promise {
+ const startedAt = Date.now();
+ for (;;) {
+ if (Date.now() - startedAt > xhr.timeout) {
+ throw new Error(`Upload timed out after ${Math.round(xhr.timeout / 1000)} seconds.`);
+ }
+ await new Promise((r) => setTimeout(r, 1500));
+ const res = await fetch(statusUrl, { cache: 'no-store' });
+ const body = await res.json().catch(() => null);
+ if (!res.ok) {
+ throw new Error(String(body?.error ?? `Upload polling failed (HTTP ${res.status}).`));
+ }
+ if (body?.pending) {
+ args.onProgress?.(100);
+ continue;
+ }
+ if (!body?.ok || !body?.upload?.url) {
+ throw new Error(String(body?.error ?? 'Upload failed before the server returned a usable result.'));
+ }
+ return {
+ url: String(body.upload.url),
+ cid: body.upload.cid ? String(body.upload.cid) : null,
+ size: Number.isFinite(Number(body.upload.size)) ? Number(body.upload.size) : null,
+ provider: body.upload.provider ? String(body.upload.provider) : config.provider,
+ runnerMode: body.upload.runnerMode ? String(body.upload.runnerMode) : config.runnerMode,
+ contentType: body.upload.contentType ? String(body.upload.contentType) : null,
+ metadata: body.upload.metadata && typeof body.upload.metadata === 'object' ? body.upload.metadata : {}
+ };
}
+ }
- if (xhr.status < 200 || xhr.status >= 300 || !body?.ok || !body?.upload?.url) {
- reject(new Error(String(body?.error ?? `Upload failed (HTTP ${xhr.status}).`)));
- return;
- }
-
- resolve({
- url: String(body.upload.url),
- cid: body.upload.cid ? String(body.upload.cid) : null,
- size: Number.isFinite(Number(body.upload.size)) ? Number(body.upload.size) : null,
- provider: body.upload.provider ? String(body.upload.provider) : config.provider,
- runnerMode: body.upload.runnerMode ? String(body.upload.runnerMode) : config.runnerMode,
- contentType: body.upload.contentType ? String(body.upload.contentType) : null,
- metadata: body.upload.metadata && typeof body.upload.metadata === 'object' ? body.upload.metadata : {}
- });
+ xhr.onerror = () => {
+ void (async () => finishReject(new Error(await buildUploadNetworkError(config, xhr))))();
+ };
+ xhr.onabort = () => finishReject(new Error('Upload request was aborted.'));
+ xhr.ontimeout = () => finishReject(new Error(`Upload timed out after ${Math.round(xhr.timeout / 1000)} seconds.`));
+ xhr.onload = () => {
+ void (async () => {
+ let body: any = null;
+ try {
+ body = xhr.responseText ? JSON.parse(xhr.responseText) : null;
+ } catch {
+ body = null;
+ }
+
+ if (xhr.status === 202 && body?.pending && body?.jobId) {
+ const pending = body as PendingUploadResponse;
+ const statusUrl = normalizeUrl(pending.statusUrl || '', `${config.statusUrl}?jobId=${encodeURIComponent(pending.jobId)}`);
+ const completed = await pollUploadJob(statusUrl);
+ finishResolve(completed);
+ return;
+ }
+
+ if (xhr.status < 200 || xhr.status >= 300 || !body?.ok || !body?.upload?.url) {
+ finishReject(new Error(String(body?.error ?? `Upload failed (HTTP ${xhr.status}).`)));
+ return;
+ }
+
+ finishResolve({
+ url: String(body.upload.url),
+ cid: body.upload.cid ? String(body.upload.cid) : null,
+ size: Number.isFinite(Number(body.upload.size)) ? Number(body.upload.size) : null,
+ provider: body.upload.provider ? String(body.upload.provider) : config.provider,
+ runnerMode: body.upload.runnerMode ? String(body.upload.runnerMode) : config.runnerMode,
+ contentType: body.upload.contentType ? String(body.upload.contentType) : null,
+ metadata: body.upload.metadata && typeof body.upload.metadata === 'object' ? body.upload.metadata : {}
+ });
+ })().catch((error: any) => finishReject(new Error(String(error?.message ?? error))));
};
xhr.send(args.file);
diff --git a/packages/templates/next-export-ui/src/theme/tokens.json b/packages/templates/next-export-ui/src/theme/tokens.json
index c0085a9..258e3ef 100644
--- a/packages/templates/next-export-ui/src/theme/tokens.json
+++ b/packages/templates/next-export-ui/src/theme/tokens.json
@@ -1,37 +1,37 @@
{
"colors": {
"bg": "#ffffff",
- "bgAlt": "#eef4ff",
- "panel": "#f4f8ff",
- "panelStrong": "#eaf2ff",
- "border": "#c9dbff",
- "text": "#0a255f",
- "muted": "#486ca6",
- "primary": "#0f56e0",
- "primaryStrong": "#0943b8",
- "accent": "#ffc700",
- "success": "#09995a",
- "danger": "#c52f44"
+ "bgAlt": "#ffffff",
+ "panel": "#ffffff",
+ "panelStrong": "#ffffff",
+ "border": "#d6dfeb",
+ "text": "#0f1729",
+ "muted": "#5f6f85",
+ "primary": "#00d4ff",
+ "primaryStrong": "#00b8e6",
+ "accent": "#ff80ff",
+ "success": "#1b9847",
+ "danger": "#ef4444"
},
"radius": {
- "sm": "10px",
- "md": "14px",
- "lg": "20px"
+ "sm": "0px",
+ "md": "0px",
+ "lg": "0px"
},
"spacing": {
"xs": "6px",
"sm": "10px",
"md": "16px",
"lg": "24px",
- "xl": "36px"
+ "xl": "38px"
},
"typography": {
"display": "\"Montserrat\", \"Avenir Next\", \"Segoe UI\", sans-serif",
- "body": "\"Inter\", \"Segoe UI\", sans-serif",
- "mono": "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace"
+ "body": "\"Montserrat\", \"Avenir Next\", \"Segoe UI\", sans-serif",
+ "mono": "\"JetBrains Mono\", \"SFMono-Regular\", Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace"
},
"motion": {
- "fast": "120ms",
- "base": "180ms"
+ "fast": "140ms",
+ "base": "220ms"
}
}
diff --git a/test/integration/testCliLocalIntegration.js b/test/integration/testCliLocalIntegration.js
index c65c797..561c7ff 100644
--- a/test/integration/testCliLocalIntegration.js
+++ b/test/integration/testCliLocalIntegration.js
@@ -161,4 +161,65 @@ describe('CLI local integration (anvil + preview + relay)', function () {
preview.kill('SIGINT');
}
});
+
+ it('serves the active local preview RPC in the manifest when preview uses a non-default anvil port', async function () {
+ this.timeout(180000);
+
+ if (!hasAnvil()) this.skip();
+
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'th-integration-preview-rpc-'));
+ const schemaPath = path.join(dir, 'schema.json');
+ const outDir = path.join(dir, 'out');
+ writeJson(schemaPath, schemaForIntegration());
+
+ const buildRes = runTh(['build', schemaPath, '--out', outDir], process.cwd());
+ expect(buildRes.status, buildRes.stderr || buildRes.stdout).to.equal(0);
+
+ const rpcHost = '127.0.0.1';
+ const rpcPort = 43000 + Math.floor(Math.random() * 2000);
+ const rpcUrl = `http://${rpcHost}:${rpcPort}`;
+ const previewHost = '127.0.0.1';
+ const previewPort = 45000 + Math.floor(Math.random() * 2000);
+ const previewBaseUrl = `http://${previewHost}:${previewPort}`;
+
+ const anvil = spawn('anvil', ['--host', rpcHost, '--port', String(rpcPort), '--chain-id', '31337'], {
+ cwd: process.cwd(),
+ stdio: ['ignore', 'pipe', 'pipe']
+ });
+
+ let preview = null;
+ try {
+ await waitForOutput(anvil, /Listening on/, 30000);
+
+ const deployRes = runTh(['deploy', outDir, '--chain', 'anvil', '--rpc', rpcUrl], process.cwd());
+ expect(deployRes.status, deployRes.stderr || deployRes.stdout).to.equal(0);
+
+ preview = spawn(
+ 'node',
+ [
+ path.resolve('packages/cli/dist/index.js'),
+ 'preview',
+ outDir,
+ '--host',
+ previewHost,
+ '--port',
+ String(previewPort),
+ '--rpc',
+ rpcUrl,
+ '--no-deploy',
+ '--no-start-anvil'
+ ],
+ { cwd: process.cwd(), stdio: ['ignore', 'pipe', 'pipe'] }
+ );
+
+ await waitForOutput(preview, new RegExp(`${previewBaseUrl}/`), 60000);
+
+ const manifestRes = await requestJson(`${previewBaseUrl}/.well-known/tokenhost/manifest.json`);
+ expect(manifestRes.status).to.equal(200);
+ expect(manifestRes.json?.extensions?.localPreview?.rpcUrl).to.equal(rpcUrl);
+ } finally {
+ if (preview) preview.kill('SIGINT');
+ anvil.kill('SIGINT');
+ }
+ });
});
diff --git a/test/integration/testCliUploadPreviewIntegration.js b/test/integration/testCliUploadPreviewIntegration.js
index eb92118..4b3c9bb 100644
--- a/test/integration/testCliUploadPreviewIntegration.js
+++ b/test/integration/testCliUploadPreviewIntegration.js
@@ -216,4 +216,156 @@ describe('CLI local preview upload integration', function () {
preview.kill('SIGINT');
}
});
+
+ it('supports async foc-process uploads with job polling', async function () {
+ this.timeout(180000);
+
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'th-upload-preview-foc-async-'));
+ const schemaPath = path.join(dir, 'schema.json');
+ const outDir = path.join(dir, 'out');
+ const fakeCliPath = path.join(dir, 'fake-foc-cli.mjs');
+ writeJson(schemaPath, uploadSchema());
+
+ fs.writeFileSync(
+ fakeCliPath,
+ `#!/usr/bin/env node
+import fs from 'fs';
+const args = process.argv.slice(2);
+const cmd = args[0];
+if (cmd === 'wallet' && args[1] === 'init') {
+ const keyIndex = args.indexOf('--privateKey');
+ const privateKey = keyIndex >= 0 ? String(args[keyIndex + 1] || '') : '';
+ const configDir = process.env.XDG_CONFIG_HOME
+ ? process.env.XDG_CONFIG_HOME + '/foc-cli-nodejs'
+ : process.env.HOME + '/.config/foc-cli-nodejs';
+ fs.mkdirSync(configDir, { recursive: true });
+ fs.writeFileSync(configDir + '/config.json', JSON.stringify({ privateKey }, null, 2));
+ process.stdout.write(JSON.stringify({ ok: true, data: { privateKey } }));
+ process.exit(0);
+}
+if (cmd === 'upload') {
+ const fileArg = args.find((value) => value && !value.startsWith('-') && value !== 'upload') || '';
+ const stat = fs.statSync(fileArg);
+ await new Promise((resolve) => setTimeout(resolve, 250));
+ process.stdout.write(JSON.stringify({
+ ok: true,
+ data: {
+ status: 'uploaded',
+ result: {
+ pieceCid: 'bafkfakeasyncuploadcid',
+ pieceScannerUrl: 'https://scanner.example/piece/bafkfakeasyncuploadcid',
+ size: stat.size,
+ copyResults: [
+ {
+ url: 'https://uploads.example.test/piece/bafkfakeasyncuploadcid',
+ providerRole: 'primary'
+ }
+ ],
+ copyFailures: []
+ },
+ processLog: [
+ { step: 'Reading file', status: 'done' },
+ { step: 'Uploading file', status: 'done' }
+ ]
+ }
+ }));
+ process.exit(0);
+}
+process.stderr.write(JSON.stringify({ ok: false, error: { message: 'unsupported fake foc command', args } }));
+process.exit(1);
+`
+ );
+ fs.chmodSync(fakeCliPath, 0o755);
+
+ const buildRes = runTh(['build', schemaPath, '--out', outDir], process.cwd(), {
+ TH_UPLOAD_RUNNER: 'foc-process',
+ TH_UPLOAD_PROVIDER: 'foc'
+ });
+ expect(buildRes.status, buildRes.stderr || buildRes.stdout).to.equal(0);
+
+ const port = 45200 + Math.floor(Math.random() * 1000);
+ const host = '127.0.0.1';
+ const baseUrl = `http://${host}:${port}`;
+
+ const preview = spawn(
+ 'node',
+ [
+ path.resolve('packages/cli/dist/index.js'),
+ 'preview',
+ outDir,
+ '--host',
+ host,
+ '--port',
+ String(port),
+ '--no-deploy',
+ '--no-start-anvil',
+ '--no-faucet'
+ ],
+ {
+ cwd: process.cwd(),
+ stdio: ['ignore', 'pipe', 'pipe'],
+ env: {
+ ...process.env,
+ TH_UPLOAD_RUNNER: 'foc-process',
+ TH_UPLOAD_PROVIDER: 'foc',
+ TH_UPLOAD_FOC_COMMAND: `node ${fakeCliPath}`,
+ TH_UPLOAD_FOC_DEBUG: '1',
+ TH_UPLOAD_FOC_COPIES: '1',
+ TH_UPLOAD_FOC_CHAIN: '314159',
+ PRIVATE_KEY: 'fff91c6963a11a8ff48f13297185f110678b47086992b0f1612b7a1467d11f0c',
+ XDG_CONFIG_HOME: path.join(dir, 'xdg-config')
+ }
+ }
+ );
+
+ try {
+ await waitForOutput(preview, new RegExp(`http://${host}:${port}/`), 60000);
+
+ const uploadStatus = await request(`${baseUrl}/__tokenhost/upload`);
+ expect(uploadStatus.status).to.equal(200);
+ expect(uploadStatus.json?.ok).to.equal(true);
+ expect(uploadStatus.json?.runnerMode).to.equal('foc-process');
+ expect(uploadStatus.json?.provider).to.equal('filecoin_onchain_cloud');
+
+ const payload = Buffer.from('fake-foc-async-upload', 'utf-8');
+ const accepted = await request(`${baseUrl}/__tokenhost/upload`, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'image/png',
+ 'x-tokenhost-upload-filename': 'async.png',
+ 'x-tokenhost-upload-size': String(payload.length),
+ 'x-tokenhost-upload-mode': 'async'
+ },
+ body: payload
+ });
+
+ expect(accepted.status).to.equal(202);
+ expect(accepted.json?.ok).to.equal(true);
+ expect(accepted.json?.pending).to.equal(true);
+ expect(String(accepted.json?.jobId || '')).to.not.equal('');
+ expect(String(accepted.json?.statusUrl || '')).to.match(/^\/__tokenhost\/upload\?jobId=/);
+
+ let completed = null;
+ for (let attempt = 0; attempt < 20; attempt += 1) {
+ await new Promise((resolve) => setTimeout(resolve, 250));
+ const polled = await request(`${baseUrl}${accepted.json.statusUrl}`);
+ expect(polled.status).to.equal(200);
+ if (polled.json?.pending) continue;
+ completed = polled.json;
+ break;
+ }
+
+ expect(completed?.ok).to.equal(true);
+ expect(completed?.done).to.equal(true);
+ expect(completed?.upload?.url).to.equal('https://uploads.example.test/piece/bafkfakeasyncuploadcid');
+ expect(completed?.upload?.cid).to.equal('bafkfakeasyncuploadcid');
+
+ const finalStatus = await request(`${baseUrl}/__tokenhost/upload`);
+ expect(finalStatus.status).to.equal(200);
+ expect(finalStatus.json?.lastError).to.equal(null);
+ expect(String(finalStatus.json?.lastSuccessAt || '')).to.not.equal('');
+ } finally {
+ preview.kill('SIGINT');
+ }
+ });
});
diff --git a/test/testCliBuildArtifacts.js b/test/testCliBuildArtifacts.js
index 56d05f3..43e0d69 100644
--- a/test/testCliBuildArtifacts.js
+++ b/test/testCliBuildArtifacts.js
@@ -84,11 +84,15 @@ describe('th build (artifacts)', function () {
expect(res.status, res.stderr || res.stdout).to.equal(0);
const appSol = fs.readFileSync(path.join(outDir, 'contracts', 'App.sol'), 'utf-8');
+ const manifest = JSON.parse(fs.readFileSync(path.join(outDir, 'manifest.json'), 'utf-8'));
expect(appSol).to.include('uint256 public constant MAX_LIST_LIMIT = 25;');
expect(appSol).to.include('uint256 public constant MAX_SCAN_STEPS = 500;');
expect(appSol).to.include('uint256 public constant MAX_MULTICALL_CALLS = 12;');
expect(appSol).to.include('uint256 public constant MAX_TOKENIZED_INDEX_TOKENS = 6;');
expect(appSol).to.include('uint256 public constant MAX_TOKENIZED_INDEX_TOKEN_LENGTH = 24;');
+ expect(manifest?.extensions?.chainLimits?.lists?.maxLimit).to.equal(25);
+ expect(manifest?.extensions?.chainLimits?.lists?.maxScanSteps).to.equal(500);
+ expect(manifest?.extensions?.chainLimits?.multicall?.maxCalls).to.equal(12);
});
it('cleans its temporary UI build workspace after a successful build', function () {
diff --git a/test/testCliGenerateUi.js b/test/testCliGenerateUi.js
index 8cc0a9d..ea6829e 100644
--- a/test/testCliGenerateUi.js
+++ b/test/testCliGenerateUi.js
@@ -170,10 +170,43 @@ describe('th generate (UI template)', function () {
const layoutSource = fs.readFileSync(path.join(outDir, 'ui', 'app', 'layout.tsx'), 'utf-8');
expect(layoutSource).to.include('NetworkStatus');
- expect(layoutSource).to.include('rootStyleVars');
+ expect(layoutSource).to.include('themeBootScript');
+ expect(layoutSource).to.not.include('/tokenhost/ops');
const generatedTokens = fs.readFileSync(path.join(outDir, 'ui', 'src', 'theme', 'tokens.json'), 'utf-8');
expect(generatedTokens).to.equal(readTemplateThemeTokens());
+ expect(generatedTokens).to.include('"primary": "#00d4ff"');
+
+ const generatedTx = fs.readFileSync(path.join(outDir, 'ui', 'src', 'lib', 'tx.ts'), 'utf-8');
+ expect(generatedTx).to.include('const relayReceipt = body.receipt ?? null;');
+ expect(generatedTx).to.include("if (relayReceipt) {");
+ expect(generatedTx).to.include('assertWalletRpcMatchesDeployment');
+ expect(generatedTx).to.include('Wallet RPC is not pointed at the same deployment as this app.');
+ expect(generatedTx).to.include('async function estimateWriteGas');
+ expect(generatedTx).to.include('const gas = await estimateWriteGas');
+ expect(generatedTx).to.include('gas,');
+
+ const generatedCollectionPage = fs.readFileSync(path.join(outDir, 'ui', 'app', '[collection]', 'ClientPage.tsx'), 'utf-8');
+ expect(generatedCollectionPage).to.include('getReadRpcUrl');
+ expect(generatedCollectionPage).to.include('rpcOverride || getReadRpcUrl(m) || undefined');
+
+ const generatedRuntime = fs.readFileSync(path.join(outDir, 'ui', 'src', 'lib', 'runtime.ts'), 'utf-8');
+ expect(generatedRuntime).to.include('getListMaxLimit');
+ expect(generatedRuntime).to.include('function clampListPageSize');
+
+ const generatedImageField = fs.readFileSync(path.join(outDir, 'ui', 'src', 'components', 'ImageFieldInput.tsx'), 'utf-8');
+ expect(generatedImageField).to.include('onBusyChange');
+
+ const generatedUpload = fs.readFileSync(path.join(outDir, 'ui', 'src', 'lib', 'upload.ts'), 'utf-8');
+ expect(generatedUpload).to.include('buildUploadNetworkError');
+ expect(generatedUpload).to.include('xhr.timeout = 5 * 60 * 1000;');
+
+ const generatedClients = fs.readFileSync(path.join(outDir, 'ui', 'src', 'lib', 'clients.ts'), 'utf-8');
+ expect(generatedClients).to.include('async function refreshWalletChainConfig');
+ expect(generatedClients).to.include("requestProvider('wallet_addEthereumChain'");
+ expect(generatedClients).to.include("requestProvider('wallet_switchEthereumChain'");
+ expect(generatedClients).to.include('async function assertWalletTracksTargetLocalRpc');
+ expect(generatedClients).to.include('export function makeInjectedPublicClient');
});
it('materializes the explicit cyber-grid theme preset into generated UI output', function () {
@@ -257,8 +290,11 @@ describe('th generate (UI template)', function () {
const uiDir = path.join(outDir, 'ui');
expect(fs.existsSync(path.join(uiDir, 'app', 'page.tsx'))).to.equal(true);
expect(fs.existsSync(path.join(uiDir, 'app', 'tag', 'page.tsx'))).to.equal(true);
+ expect(fs.existsSync(path.join(uiDir, 'app', 'Post', 'page.tsx'))).to.equal(true);
const generatedThs = fs.readFileSync(path.join(uiDir, 'src', 'generated', 'ths.ts'), 'utf-8');
expect(generatedThs).to.include('"preset": "cyber-grid"');
+ expect(generatedThs).to.include('"authorProfile"');
+ expect(generatedThs).to.not.include('"authorHandle"');
const install = runCmd('pnpm', ['install'], uiDir);
expect(install.status, install.stderr || install.stdout).to.equal(0);