1112 lines
36 KiB
HTML
1112 lines
36 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en" dir="ltr">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Edut — Deterministic Governance Infrastructure</title>
|
|
<meta name="description" content="Edut LLC. Development and licensing of deterministic governance systems. A local-first framework for governed business operations.">
|
|
<meta name="theme-color" content="#f0f4f8">
|
|
|
|
<!-- Open Graph: Math Layer -->
|
|
<meta property="og:title" content="Edut — Deterministic Governance Systems">
|
|
<meta property="og:description" content="Development and licensing of deterministic governance systems. Local-first infrastructure with deterministic controls, evidence trails, and optional intelligence layers.">
|
|
<meta property="og:type" content="website">
|
|
<meta property="og:url" content="https://edut.ai">
|
|
<meta property="og:site_name" content="Edut">
|
|
|
|
<!-- Math Layer: Structured Data -->
|
|
<script type="application/ld+json">
|
|
{
|
|
"@context": "https://schema.org",
|
|
"@type": "Organization",
|
|
"name": "Edut LLC",
|
|
"url": "https://edut.ai",
|
|
"description": "Development and licensing of deterministic governance systems",
|
|
"foundingDate": "2026",
|
|
"address": {
|
|
"@type": "PostalAddress",
|
|
"addressLocality": "Herald",
|
|
"addressRegion": "CA",
|
|
"postalCode": "95638",
|
|
"addressCountry": "US"
|
|
},
|
|
"sameAs": [
|
|
"https://edut.dev"
|
|
]
|
|
}
|
|
</script>
|
|
|
|
<!-- AI + Accessibility Context: visible to screen readers and AI, hidden visually -->
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500&display=swap');
|
|
|
|
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
|
html, body { height: 100%; overflow: hidden; }
|
|
|
|
body {
|
|
font-family: 'IBM Plex Mono', 'Courier New', Courier, monospace;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 100vh;
|
|
position: relative;
|
|
cursor: pointer;
|
|
-webkit-font-smoothing: antialiased;
|
|
-moz-osx-font-smoothing: grayscale;
|
|
|
|
/* Clean white background */
|
|
background: #f0f4f8;
|
|
}
|
|
|
|
/* Ambient glow handled by Three.js instead */
|
|
|
|
.skip-link {
|
|
position: absolute;
|
|
top: -100px;
|
|
left: 8px;
|
|
background: #2c2c2c;
|
|
color: #f0f4f8;
|
|
padding: 8px 16px;
|
|
font-size: 13px;
|
|
z-index: 1000;
|
|
text-decoration: none;
|
|
border-radius: 2px;
|
|
}
|
|
.skip-link:focus {
|
|
top: 8px;
|
|
outline: 2px solid #2c2c2c;
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.wrapper {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
pointer-events: none;
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
|
|
#globe-container {
|
|
width: 420px;
|
|
height: 420px;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.identity {
|
|
text-align: center;
|
|
margin-top: 28px;
|
|
}
|
|
|
|
/* Identity name: 16px, needs 4.5:1 for AA normal text */
|
|
.identity-name {
|
|
font-size: 16px;
|
|
letter-spacing: 0.5em;
|
|
text-transform: uppercase;
|
|
color: #3a3d42;
|
|
font-weight: 400;
|
|
transition: opacity 0.6s ease, letter-spacing 0.6s ease;
|
|
cursor: pointer;
|
|
}
|
|
.identity-name:hover {
|
|
color: #2c2c2c;
|
|
letter-spacing: 0.6em;
|
|
}
|
|
|
|
/* Definition: needs to be readable */
|
|
.identity-definition {
|
|
font-size: 13px;
|
|
letter-spacing: 0.25em;
|
|
color: #555a60;
|
|
font-weight: 300;
|
|
margin-top: 10px;
|
|
transition: color 0.6s ease;
|
|
cursor: pointer;
|
|
}
|
|
.identity-definition:hover { color: #3a3d42; }
|
|
|
|
/* Descriptor */
|
|
.identity-descriptor {
|
|
font-size: 12px;
|
|
letter-spacing: 0.15em;
|
|
color: #606264;
|
|
font-weight: 300;
|
|
margin-top: 8px;
|
|
transition: color 0.6s ease;
|
|
cursor: pointer;
|
|
}
|
|
.identity-descriptor:hover { color: #555a60; }
|
|
|
|
/* Acknowledged token */
|
|
.acknowledged {
|
|
font-size: 12px;
|
|
letter-spacing: 0.3em;
|
|
color: #606264;
|
|
font-weight: 300;
|
|
margin-top: 24px;
|
|
opacity: 0;
|
|
transition: opacity 1.5s ease;
|
|
cursor: default;
|
|
}
|
|
.acknowledged.visible { opacity: 1; }
|
|
.acknowledged.visible:hover { color: #555a60; }
|
|
|
|
.flow-ui {
|
|
margin-top: 18px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 10px;
|
|
pointer-events: auto;
|
|
}
|
|
|
|
.flow-hidden {
|
|
display: none !important;
|
|
}
|
|
|
|
.ghost-action {
|
|
border: 0;
|
|
background: transparent;
|
|
color: #606264;
|
|
font-family: 'IBM Plex Mono', 'Courier New', Courier, monospace;
|
|
font-size: 12px;
|
|
letter-spacing: 0.2em;
|
|
text-transform: lowercase;
|
|
font-weight: 300;
|
|
cursor: pointer;
|
|
opacity: 0;
|
|
transition: opacity 500ms ease, color 300ms ease;
|
|
}
|
|
.ghost-action:hover {
|
|
color: #2c2c2c;
|
|
}
|
|
.ghost-action.visible {
|
|
opacity: 1;
|
|
}
|
|
|
|
.flow-panel {
|
|
max-width: 480px;
|
|
text-align: center;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
opacity: 0;
|
|
transition: opacity 500ms ease;
|
|
}
|
|
.flow-panel.visible {
|
|
opacity: 1;
|
|
}
|
|
|
|
.flow-line {
|
|
font-size: 12px;
|
|
letter-spacing: 0.08em;
|
|
color: #555a60;
|
|
font-weight: 300;
|
|
line-height: 1.7;
|
|
}
|
|
.flow-line.subtle {
|
|
font-size: 11px;
|
|
color: #6b7078;
|
|
}
|
|
|
|
.flow-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 16px;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.flow-link {
|
|
border: 0;
|
|
background: transparent;
|
|
color: #555a60;
|
|
font-family: 'IBM Plex Mono', 'Courier New', Courier, monospace;
|
|
font-size: 11px;
|
|
letter-spacing: 0.12em;
|
|
text-transform: lowercase;
|
|
font-weight: 300;
|
|
cursor: pointer;
|
|
text-decoration: underline;
|
|
text-underline-offset: 2px;
|
|
}
|
|
.flow-link:hover {
|
|
color: #2c2c2c;
|
|
}
|
|
|
|
.flow-status {
|
|
font-size: 11px;
|
|
letter-spacing: 0.12em;
|
|
color: #606264;
|
|
font-weight: 300;
|
|
opacity: 0;
|
|
transition: opacity 500ms ease;
|
|
min-height: 16px;
|
|
}
|
|
.flow-status.visible {
|
|
opacity: 1;
|
|
}
|
|
.flow-status.error {
|
|
color: #7a3a3a;
|
|
}
|
|
|
|
.delivery-panel {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
align-items: center;
|
|
opacity: 0;
|
|
transition: opacity 500ms ease;
|
|
}
|
|
.delivery-panel.visible {
|
|
opacity: 1;
|
|
}
|
|
|
|
.download-links {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 14px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
/* Footer */
|
|
footer {
|
|
position: fixed;
|
|
bottom: 0; left: 0; right: 0;
|
|
padding: 16px 24px;
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 16px;
|
|
align-items: center;
|
|
font-size: 11px;
|
|
letter-spacing: 0.1em;
|
|
font-weight: 300;
|
|
pointer-events: auto;
|
|
z-index: 10;
|
|
}
|
|
footer span {
|
|
color: #606264;
|
|
}
|
|
footer a {
|
|
color: #555a60;
|
|
text-decoration: none;
|
|
text-transform: uppercase;
|
|
transition: color 0.4s ease;
|
|
pointer-events: auto;
|
|
}
|
|
footer a:hover { color: #2c2c2c; }
|
|
footer a:focus-visible {
|
|
outline: 2px solid #2c2c2c;
|
|
outline-offset: 3px;
|
|
border-radius: 2px;
|
|
}
|
|
|
|
/* Screen reader only text */
|
|
.sr-only {
|
|
position: absolute;
|
|
width: 1px;
|
|
height: 1px;
|
|
padding: 0;
|
|
margin: -1px;
|
|
overflow: hidden;
|
|
clip: rect(0, 0, 0, 0);
|
|
white-space: nowrap;
|
|
border: 0;
|
|
}
|
|
|
|
/* Context Layer: accessible to screen readers + AI, hidden visually
|
|
Uses same technique as sr-only but as a full section.
|
|
Future: toggling .context-layer--visible reveals it on screen. */
|
|
.context-layer {
|
|
position: absolute;
|
|
width: 1px;
|
|
height: 1px;
|
|
padding: 0;
|
|
margin: -1px;
|
|
overflow: hidden;
|
|
clip: rect(0, 0, 0, 0);
|
|
white-space: normal;
|
|
border: 0;
|
|
}
|
|
.context-layer h2,
|
|
.context-layer h3 {
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
margin-top: 16px;
|
|
margin-bottom: 8px;
|
|
}
|
|
.context-layer p {
|
|
font-size: 14px;
|
|
font-weight: 300;
|
|
line-height: 1.8;
|
|
color: #3a3a3a;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.localizable[dir="rtl"] {
|
|
direction: rtl;
|
|
unicode-bidi: plaintext;
|
|
}
|
|
|
|
/* Future: when activated, reveals context layer as readable overlay */
|
|
.context-layer--visible {
|
|
position: fixed;
|
|
top: 0; left: 0; right: 0; bottom: 0;
|
|
width: auto;
|
|
height: auto;
|
|
margin: 0;
|
|
clip: auto;
|
|
overflow-y: auto;
|
|
background: #f0f4f8;
|
|
padding: 60px 24px;
|
|
z-index: 100;
|
|
display: flex;
|
|
justify-content: center;
|
|
}
|
|
.context-layer--visible article {
|
|
max-width: 640px;
|
|
width: 100%;
|
|
}
|
|
|
|
@media (max-width: 600px) {
|
|
#globe-container { width: 300px; height: 300px; }
|
|
.identity-name { font-size: 14px; letter-spacing: 0.4em; }
|
|
.identity-definition { font-size: 12px; }
|
|
.identity-descriptor { font-size: 11px; }
|
|
body::before { width: 350px; height: 350px; }
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
*, *::before, *::after {
|
|
transition: none !important;
|
|
animation: none !important;
|
|
}
|
|
body::before { animation: none; opacity: 0.8; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<a href="#main-content" class="skip-link localizable" data-i18n="skip_to_content">Skip to content</a>
|
|
|
|
<div class="wrapper" role="main" id="main-content">
|
|
<div id="globe-container" aria-hidden="true"></div>
|
|
<div class="identity">
|
|
<h1 class="identity-name">edut · עֵדוּת</h1>
|
|
<p class="identity-definition localizable" data-i18n="definition">testimony · witness · evidence</p>
|
|
<p class="identity-descriptor localizable" data-i18n="descriptor">Development and licensing of deterministic governance systems</p>
|
|
</div>
|
|
<div class="acknowledged" id="acknowledged" aria-live="polite"></div>
|
|
<div class="flow-ui" id="flow-ui">
|
|
<button id="continue-action" class="ghost-action localizable flow-hidden" data-i18n="continue_label" type="button">continue</button>
|
|
<div id="wallet-panel" class="flow-panel flow-hidden">
|
|
<p class="flow-line localizable" data-i18n="wallet_intro">mint your membership</p>
|
|
<p class="flow-line subtle localizable" data-i18n="wallet_fact_no_tx">No transaction. Signature only.</p>
|
|
<p class="flow-line subtle localizable" data-i18n="wallet_fact_seed">Never share your seed phrase.</p>
|
|
<div class="flow-actions">
|
|
<button id="wallet-have" class="flow-link localizable" data-i18n="wallet_have" type="button">I have a wallet</button>
|
|
<button id="wallet-need" class="flow-link localizable" data-i18n="wallet_need" type="button">I need a wallet</button>
|
|
</div>
|
|
<div id="wallet-install-help" class="flow-hidden">
|
|
<p class="flow-line subtle localizable" data-i18n="wallet_install_help">Install a wallet app, then return here to continue.</p>
|
|
<a id="wallet-install-link" class="flow-link localizable" data-i18n="wallet_install_cta" href="https://www.coinbase.com/wallet/downloads" target="_blank" rel="noopener noreferrer">Install wallet</a>
|
|
</div>
|
|
</div>
|
|
<div id="flow-status" class="flow-status flow-hidden" aria-live="polite"></div>
|
|
<div id="delivery-panel" class="delivery-panel flow-hidden">
|
|
<p class="flow-line localizable" data-i18n="download_heading">download your platform</p>
|
|
<div class="download-links">
|
|
<a id="download-desktop" class="flow-link localizable" data-i18n="download_desktop" href="/downloads/desktop">desktop</a>
|
|
<a id="download-ios" class="flow-link localizable" data-i18n="download_ios" href="/downloads/ios">iOS</a>
|
|
<a id="download-android" class="flow-link localizable" data-i18n="download_android" href="/downloads/android">android</a>
|
|
</div>
|
|
<p class="flow-line subtle localizable" data-i18n="app_notifications_note">member updates are delivered inside the app after wallet sign-in.</p>
|
|
</div>
|
|
</div>
|
|
<span class="sr-only localizable" id="interaction-hint" data-i18n="interaction_hint">Click anywhere on the page to begin your access request.</span>
|
|
</div>
|
|
|
|
<!-- Context Layer: accessible to screen readers, AI systems, and future audio playback -->
|
|
<section id="context-layer" class="context-layer" aria-label="About Edut">
|
|
<article>
|
|
<h2 class="localizable" data-i18n="context.heading">Research Abstract</h2>
|
|
<p class="localizable" data-i18n="context.intro">Edut (Hebrew: עֵדוּת — testimony, witness, evidence) is a systems research and licensing organization focused on deterministic governance infrastructure for real-world operations.</p>
|
|
<p class="localizable" data-i18n="context.thesis">The central thesis is simple: operational dependence should not require permanent dependence on outside control planes. Infrastructure should remain useful under constrained connectivity, changing vendor economics, and long operating horizons.</p>
|
|
<p class="localizable" data-i18n="context.approach">Edut addresses this through a math-first architecture: deterministic data modeling, verifiable decision paths, and structured evidence before optimization layers are introduced.</p>
|
|
<h3 class="localizable" data-i18n="context.principles_heading">Framework Principles</h3>
|
|
<p class="localizable" data-i18n="context.deterministic"><strong>Deterministic Foundations.</strong> Core policy and safety decisions are computed from explicit rules and typed inputs. Outputs are testable, reproducible, and auditable.</p>
|
|
<p class="localizable" data-i18n="context.ownership"><strong>Operational Ownership.</strong> Deployments are designed for client-controlled operation on client-chosen infrastructure, with clear contractual boundaries around rights and responsibilities.</p>
|
|
<p class="localizable" data-i18n="context.layered"><strong>Layered Architecture.</strong> The human layer presents simple surfaces. The mathematical layer assembles and scores structured context. The intelligence layer is optional augmentation and never the sole dependency.</p>
|
|
<p class="localizable" data-i18n="context.permanence"><strong>Permanence by Design.</strong> Systems are engineered for long service life with upgrade paths, compatibility controls, and explicit rollback discipline.</p>
|
|
<p class="localizable" data-i18n="context.constitutional"><strong>Constitutional Governance.</strong> Mechanical invariants enforce safety boundaries consistently across modes, users, and modules.</p>
|
|
<h3 class="localizable" data-i18n="context.domains_heading">Research Domains</h3>
|
|
<p class="localizable" data-i18n="context.domains">Current research spans deterministic orchestration, workspace-isolated data graphs, connector contracts, evidence integrity, module governance, and long-horizon operational resilience.</p>
|
|
<h3 class="localizable" data-i18n="context.licensing_heading">Licensing Model</h3>
|
|
<p class="localizable" data-i18n="context.licensing">Edut uses a build-and-license model for governed software systems. Licensing is designed for durable runtime rights with explicit policy, support, and deployment boundaries.</p>
|
|
<h3 class="localizable" data-i18n="context.status_heading">Current Status</h3>
|
|
<p class="localizable" data-i18n="context.status">The framework is in active implementation and validation. Early designations are being issued for staged launch readiness and controlled deployment onboarding.</p>
|
|
<p class="localizable" data-i18n="context.note"><strong>Note:</strong> This page is intentionally minimal. It reflects a product philosophy where computation is complex and interfaces remain quiet.</p>
|
|
</article>
|
|
|
|
<!-- Future: read-aloud button
|
|
<button id="read-context" aria-label="Read about Edut">Listen</button>
|
|
-->
|
|
</section>
|
|
|
|
<footer role="contentinfo">
|
|
<span>© Edut LLC</span>
|
|
<a href="/privacy" id="privacyLink" class="localizable" data-i18n="privacy">Privacy</a>
|
|
<a href="/terms" id="termsLink" class="localizable" data-i18n="terms">Terms</a>
|
|
</footer>
|
|
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
|
<script>
|
|
const container = document.getElementById('globe-container');
|
|
const supportedLanguages = ['en', 'zh', 'es', 'ar', 'fr', 'pt', 'de', 'ja', 'ru', 'ko', 'hi', 'he'];
|
|
let activeLocale = null;
|
|
const flowState = {
|
|
firstInteraction: false,
|
|
stage: 'idle',
|
|
currentIntent: null,
|
|
};
|
|
|
|
const continueAction = document.getElementById('continue-action');
|
|
const walletPanel = document.getElementById('wallet-panel');
|
|
const walletHave = document.getElementById('wallet-have');
|
|
const walletNeed = document.getElementById('wallet-need');
|
|
const walletInstallHelp = document.getElementById('wallet-install-help');
|
|
const flowStatus = document.getElementById('flow-status');
|
|
const deliveryPanel = document.getElementById('delivery-panel');
|
|
const privacyLink = document.getElementById('privacyLink');
|
|
const termsLink = document.getElementById('termsLink');
|
|
|
|
function getByPath(obj, path) {
|
|
return path.split('.').reduce((value, part) => (value && value[part] !== undefined ? value[part] : undefined), obj);
|
|
}
|
|
|
|
function t(key, fallback) {
|
|
if (!activeLocale) return fallback;
|
|
const value = getByPath(activeLocale, key);
|
|
return typeof value === 'string' ? value : fallback;
|
|
}
|
|
|
|
function showElement(el) {
|
|
if (!el) return;
|
|
el.classList.remove('flow-hidden');
|
|
requestAnimationFrame(() => el.classList.add('visible'));
|
|
}
|
|
|
|
function hideElement(el) {
|
|
if (!el) return;
|
|
el.classList.remove('visible');
|
|
el.classList.add('flow-hidden');
|
|
}
|
|
|
|
function buildDisplayToken(code) {
|
|
if (!code || code.length < 13) return code;
|
|
return code.slice(0,4) + '-' + code.slice(4,8) + '-' + code.slice(8,12) + '-' + code.slice(12);
|
|
}
|
|
|
|
function getStoredAcknowledgement() {
|
|
const stateRaw = localStorage.getItem('edut_ack_state');
|
|
if (stateRaw) {
|
|
try {
|
|
const parsed = JSON.parse(stateRaw);
|
|
if (parsed && (parsed.code || parsed.token)) {
|
|
return parsed;
|
|
}
|
|
} catch (err) {
|
|
// ignore malformed cache
|
|
}
|
|
}
|
|
const legacyCode = localStorage.getItem('edut_acknowledged');
|
|
if (legacyCode) {
|
|
return {
|
|
code: legacyCode,
|
|
token: buildDisplayToken(legacyCode),
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function saveAcknowledgement(state) {
|
|
if (!state) return;
|
|
localStorage.setItem('edut_ack_state', JSON.stringify(state));
|
|
if (state.code) {
|
|
localStorage.setItem('edut_acknowledged', state.code);
|
|
}
|
|
}
|
|
|
|
function renderAcknowledged(state) {
|
|
if (!state) return;
|
|
const el = document.getElementById('acknowledged');
|
|
const label = t('acknowledged', 'acknowledged');
|
|
const token = state.token || buildDisplayToken(state.code);
|
|
if (!token) return;
|
|
el.textContent = label + ' · ' + token;
|
|
el.classList.add('visible');
|
|
}
|
|
|
|
function showPostMintPanel() {
|
|
hideElement(continueAction);
|
|
hideElement(walletPanel);
|
|
showElement(deliveryPanel);
|
|
}
|
|
|
|
function setFlowStatus(key, fallback, isError) {
|
|
flowStatus.textContent = t(key, fallback);
|
|
flowStatus.classList.toggle('error', !!isError);
|
|
showElement(flowStatus);
|
|
}
|
|
|
|
function setFlowStatusMessage(message, isError) {
|
|
flowStatus.textContent = message;
|
|
flowStatus.classList.toggle('error', !!isError);
|
|
showElement(flowStatus);
|
|
}
|
|
|
|
function clearFlowStatus() {
|
|
flowStatus.textContent = '';
|
|
flowStatus.classList.remove('error');
|
|
hideElement(flowStatus);
|
|
}
|
|
|
|
function formatAtomicAmount(atomicRaw, decimalsRaw) {
|
|
if (atomicRaw === undefined || atomicRaw === null) return null;
|
|
if (decimalsRaw === undefined || decimalsRaw === null) return String(atomicRaw);
|
|
const atomic = String(atomicRaw);
|
|
const decimals = Number(decimalsRaw);
|
|
if (!/^[0-9]+$/.test(atomic) || Number.isNaN(decimals) || decimals < 0) {
|
|
return null;
|
|
}
|
|
if (decimals === 0) return atomic;
|
|
const padded = atomic.padStart(decimals + 1, '0');
|
|
const intPart = padded.slice(0, -decimals);
|
|
const fracPart = padded.slice(-decimals).replace(/0+$/, '');
|
|
return fracPart ? (intPart + '.' + fracPart) : intPart;
|
|
}
|
|
|
|
function formatQuoteDisplay(quote) {
|
|
if (!quote || typeof quote !== 'object') return null;
|
|
const currency = quote.currency ? String(quote.currency) : '';
|
|
let amount = null;
|
|
if (quote.amount !== undefined && quote.amount !== null) {
|
|
amount = String(quote.amount);
|
|
} else if (quote.display_amount !== undefined && quote.display_amount !== null) {
|
|
amount = String(quote.display_amount);
|
|
} else if (quote.amount_atomic !== undefined && quote.amount_atomic !== null) {
|
|
amount = formatAtomicAmount(quote.amount_atomic, quote.decimals);
|
|
}
|
|
if (amount && currency) return amount + ' ' + currency;
|
|
if (amount) return amount;
|
|
if (currency) return currency;
|
|
return null;
|
|
}
|
|
|
|
async function postJSON(url, payload) {
|
|
const res = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!res.ok) {
|
|
const detail = await res.text();
|
|
throw new Error(detail || ('HTTP ' + res.status));
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
function resolveLanguage(input) {
|
|
if (!input) return 'en';
|
|
const normalized = String(input).toLowerCase();
|
|
if (normalized.startsWith('zh')) return 'zh';
|
|
const base = normalized.split('-')[0];
|
|
return supportedLanguages.includes(base) ? base : 'en';
|
|
}
|
|
|
|
async function loadLocaleBundle(lang) {
|
|
const candidates = [
|
|
'/translations/' + lang + '.json',
|
|
'../translations/' + lang + '.json',
|
|
'./translations/' + lang + '.json',
|
|
];
|
|
for (const url of candidates) {
|
|
try {
|
|
const res = await fetch(url, { cache: 'no-cache' });
|
|
if (!res.ok) continue;
|
|
return await res.json();
|
|
} catch (err) {
|
|
// Try next path.
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function applyLocale(bundle, lang) {
|
|
activeLocale = bundle;
|
|
localStorage.setItem('edut_lang', lang);
|
|
document.documentElement.setAttribute('lang', lang);
|
|
document.documentElement.setAttribute('dir', 'ltr');
|
|
|
|
const direction = bundle.dir === 'rtl' ? 'rtl' : 'ltr';
|
|
const nodes = document.querySelectorAll('[data-i18n]');
|
|
nodes.forEach((node) => {
|
|
const key = node.getAttribute('data-i18n');
|
|
const value = getByPath(bundle, key);
|
|
if (typeof value === 'string') {
|
|
node.textContent = value;
|
|
}
|
|
node.setAttribute('dir', direction);
|
|
});
|
|
|
|
const placeholderNodes = document.querySelectorAll('[data-i18n-placeholder]');
|
|
placeholderNodes.forEach((node) => {
|
|
const key = node.getAttribute('data-i18n-placeholder');
|
|
const value = getByPath(bundle, key);
|
|
if (typeof value === 'string') {
|
|
node.setAttribute('placeholder', value);
|
|
}
|
|
node.setAttribute('dir', direction);
|
|
});
|
|
|
|
const saved = getStoredAcknowledgement();
|
|
if (saved) {
|
|
renderAcknowledged(saved);
|
|
showPostMintPanel();
|
|
}
|
|
}
|
|
|
|
async function initializeLocale() {
|
|
const saved = localStorage.getItem('edut_lang');
|
|
const requested = resolveLanguage(saved || navigator.language || navigator.userLanguage);
|
|
const primaryBundle = await loadLocaleBundle(requested);
|
|
if (primaryBundle) {
|
|
applyLocale(primaryBundle, requested);
|
|
return;
|
|
}
|
|
const fallbackBundle = await loadLocaleBundle('en');
|
|
if (fallbackBundle) {
|
|
applyLocale(fallbackBundle, 'en');
|
|
}
|
|
}
|
|
|
|
initializeLocale();
|
|
|
|
function openWalletInstallHints() {
|
|
showElement(walletInstallHelp);
|
|
setFlowStatus('wallet_need_status', 'Install a wallet app, then return to continue.', false);
|
|
}
|
|
|
|
async function startWalletFlow() {
|
|
hideElement(walletInstallHelp);
|
|
try {
|
|
if (!window.ethereum) {
|
|
openWalletInstallHints();
|
|
setFlowStatus('wallet_missing', 'No wallet detected on this device.', true);
|
|
return;
|
|
}
|
|
|
|
setFlowStatus('wallet_connecting', 'Connecting wallet...', false);
|
|
const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
|
|
if (!Array.isArray(accounts) || accounts.length === 0) {
|
|
throw new Error('Wallet connection was not approved.');
|
|
}
|
|
const address = accounts[0];
|
|
const chainIdHex = await window.ethereum.request({ method: 'eth_chainId' });
|
|
const chainId = Number.parseInt(chainIdHex, 16);
|
|
|
|
setFlowStatus('wallet_intent', 'Creating designation intent...', false);
|
|
const intent = await postJSON('/secret/wallet/intent', {
|
|
address,
|
|
origin: window.location.origin,
|
|
locale: localStorage.getItem('edut_lang') || 'en',
|
|
chain_id: chainId,
|
|
});
|
|
flowState.currentIntent = intent;
|
|
|
|
const typedData = {
|
|
types: {
|
|
EIP712Domain: [
|
|
{ name: 'name', type: 'string' },
|
|
{ name: 'version', type: 'string' },
|
|
{ name: 'chainId', type: 'uint256' },
|
|
{ name: 'verifyingContract', type: 'address' },
|
|
],
|
|
DesignationIntent: [
|
|
{ name: 'designationCode', type: 'string' },
|
|
{ name: 'designationToken', type: 'string' },
|
|
{ name: 'nonce', type: 'string' },
|
|
{ name: 'issuedAt', type: 'string' },
|
|
{ name: 'origin', type: 'string' },
|
|
],
|
|
},
|
|
primaryType: 'DesignationIntent',
|
|
domain: {
|
|
name: intent.domain_name || 'EDUT Designation',
|
|
version: '1',
|
|
chainId: intent.chain_id || chainId,
|
|
verifyingContract: intent.verifying_contract || '0x0000000000000000000000000000000000000000',
|
|
},
|
|
message: {
|
|
designationCode: intent.designation_code,
|
|
designationToken: intent.display_token || buildDisplayToken(intent.designation_code),
|
|
nonce: intent.nonce,
|
|
issuedAt: intent.issued_at || new Date().toISOString(),
|
|
origin: window.location.origin,
|
|
},
|
|
};
|
|
|
|
setFlowStatus('wallet_signing', 'Awaiting signature...', false);
|
|
const signature = await window.ethereum.request({
|
|
method: 'eth_signTypedData_v4',
|
|
params: [address, JSON.stringify(typedData)],
|
|
});
|
|
|
|
setFlowStatus('wallet_verifying', 'Verifying signature...', false);
|
|
const verification = await postJSON('/secret/wallet/verify', {
|
|
intent_id: intent.intent_id,
|
|
address,
|
|
chain_id: chainId,
|
|
signature,
|
|
});
|
|
|
|
setFlowStatus('membership_quoting', 'Preparing membership mint...', false);
|
|
const quote = await postJSON('/secret/membership/quote', {
|
|
designation_code: verification.designation_code || intent.designation_code || null,
|
|
address,
|
|
chain_id: chainId,
|
|
});
|
|
|
|
const txParams = quote.tx || {
|
|
from: address,
|
|
to: quote.contract_address,
|
|
data: quote.calldata || '0x',
|
|
value: quote.value || '0x0',
|
|
};
|
|
|
|
if (!txParams.from) txParams.from = address;
|
|
if (!txParams.to) {
|
|
throw new Error('Membership quote is missing destination contract.');
|
|
}
|
|
|
|
const quoteDisplay = formatQuoteDisplay(quote);
|
|
const mintPrompt = t('membership_minting', 'Confirm membership mint in your wallet...');
|
|
if (quoteDisplay) {
|
|
setFlowStatusMessage(mintPrompt + ' (' + quoteDisplay + ')', false);
|
|
} else {
|
|
setFlowStatus('membership_minting', 'Confirm membership mint in your wallet...', false);
|
|
}
|
|
const txHash = await window.ethereum.request({
|
|
method: 'eth_sendTransaction',
|
|
params: [txParams],
|
|
});
|
|
|
|
setFlowStatus('membership_confirming', 'Confirming membership on-chain...', false);
|
|
const confirmation = await postJSON('/secret/membership/confirm', {
|
|
designation_code: verification.designation_code || intent.designation_code || null,
|
|
quote_id: quote.quote_id || null,
|
|
tx_hash: txHash,
|
|
address,
|
|
chain_id: chainId,
|
|
});
|
|
|
|
if (confirmation.status !== 'membership_active') {
|
|
throw new Error('Membership transaction did not activate.');
|
|
}
|
|
|
|
const ackState = {
|
|
code: confirmation.designation_code || verification.designation_code || intent.designation_code || null,
|
|
token: confirmation.display_token || verification.display_token || intent.display_token || buildDisplayToken(confirmation.designation_code || verification.designation_code || intent.designation_code),
|
|
wallet: address,
|
|
chain_id: chainId,
|
|
membership_tx_hash: txHash,
|
|
};
|
|
saveAcknowledgement(ackState);
|
|
renderAcknowledged(ackState);
|
|
showPostMintPanel();
|
|
setFlowStatus('membership_active', 'Membership active. Designation acknowledged.', false);
|
|
} catch (err) {
|
|
const message = err && err.message ? err.message : 'Wallet flow failed.';
|
|
setFlowStatus('wallet_failed', message, true);
|
|
}
|
|
}
|
|
|
|
function onFirstInteractionReveal() {
|
|
if (flowState.firstInteraction || getStoredAcknowledgement()) return;
|
|
flowState.firstInteraction = true;
|
|
showElement(continueAction);
|
|
}
|
|
|
|
// Privacy/Terms bypass all flow logic.
|
|
privacyLink.addEventListener('click', (e) => e.stopPropagation());
|
|
termsLink.addEventListener('click', (e) => e.stopPropagation());
|
|
|
|
continueAction.addEventListener('click', () => {
|
|
hideElement(continueAction);
|
|
showElement(walletPanel);
|
|
flowState.stage = 'wallet_choice';
|
|
});
|
|
walletHave.addEventListener('click', startWalletFlow);
|
|
walletNeed.addEventListener('click', openWalletInstallHints);
|
|
|
|
// Keyboard support: Enter/Space triggers same as click
|
|
document.body.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
const tag = e.target.tagName;
|
|
if (tag !== 'A' && tag !== 'BUTTON' && tag !== 'INPUT') {
|
|
e.preventDefault();
|
|
document.body.click();
|
|
}
|
|
}
|
|
});
|
|
|
|
// Interaction state
|
|
let spinSpeed = 0.15;
|
|
let targetSpin = 0.15;
|
|
let clickBoost = 0;
|
|
|
|
// Full page click keeps the globe alive; first click also reveals continue.
|
|
document.body.addEventListener('click', () => {
|
|
clickBoost = 2.5;
|
|
targetSpin = 2.0;
|
|
onFirstInteractionReveal();
|
|
});
|
|
|
|
// ============================================================
|
|
// THREE.JS — FLOATING GLOBE
|
|
// ============================================================
|
|
const width = container.clientWidth;
|
|
const height = container.clientHeight;
|
|
|
|
const scene = new THREE.Scene();
|
|
const camera = new THREE.PerspectiveCamera(42, width / height, 0.1, 100);
|
|
camera.position.set(0, 0.3, 4.2);
|
|
camera.lookAt(0, 0, 0);
|
|
|
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
|
renderer.setSize(width, height);
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
renderer.setClearColor(0x000000, 0);
|
|
container.appendChild(renderer.domElement);
|
|
|
|
// Globe group — tilted, spins
|
|
const globeGroup = new THREE.Group();
|
|
globeGroup.rotation.z = -0.3;
|
|
globeGroup.rotation.x = 0.15;
|
|
scene.add(globeGroup);
|
|
|
|
// Sphere shell — slightly more visible
|
|
const sphereGeo = new THREE.SphereGeometry(1, 64, 64);
|
|
const sphereMat = new THREE.MeshBasicMaterial({
|
|
color: 0x1a4090,
|
|
transparent: true,
|
|
opacity: 0.12,
|
|
});
|
|
const sphere = new THREE.Mesh(sphereGeo, sphereMat);
|
|
globeGroup.add(sphere);
|
|
|
|
// Latitude/Longitude wireframe — stronger
|
|
const wireGeo = new THREE.SphereGeometry(1.002, 36, 24);
|
|
const wireMat = new THREE.MeshBasicMaterial({
|
|
color: 0x1a4590,
|
|
transparent: true,
|
|
opacity: 0.14,
|
|
wireframe: true,
|
|
});
|
|
const wireframe = new THREE.Mesh(wireGeo, wireMat);
|
|
globeGroup.add(wireframe);
|
|
|
|
// Core — brighter
|
|
const coreGeo = new THREE.SphereGeometry(0.08, 16, 16);
|
|
const coreMat = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.9 });
|
|
const core = new THREE.Mesh(coreGeo, coreMat);
|
|
globeGroup.add(core);
|
|
|
|
const coreGlowGeo = new THREE.SphereGeometry(0.25, 16, 16);
|
|
const coreGlowMat = new THREE.MeshBasicMaterial({ color: 0x3060c0, transparent: true, opacity: 0.25 });
|
|
const coreGlow = new THREE.Mesh(coreGlowGeo, coreGlowMat);
|
|
globeGroup.add(coreGlow);
|
|
|
|
// Rays — more visible
|
|
const NUM_RAYS = 80;
|
|
const rayGroup = new THREE.Group();
|
|
globeGroup.add(rayGroup);
|
|
const rayData = [];
|
|
|
|
for (let i = 0; i < NUM_RAYS; i++) {
|
|
const phi = Math.acos(2 * Math.random() - 1);
|
|
const theta = Math.random() * Math.PI * 2;
|
|
const len = 0.5 + Math.random() * 0.45;
|
|
const endX = len * Math.sin(phi) * Math.cos(theta);
|
|
const endY = len * Math.cos(phi);
|
|
const endZ = len * Math.sin(phi) * Math.sin(theta);
|
|
|
|
const geo = new THREE.BufferGeometry().setFromPoints([
|
|
new THREE.Vector3(0, 0, 0),
|
|
new THREE.Vector3(endX, endY, endZ)
|
|
]);
|
|
const mat = new THREE.LineBasicMaterial({
|
|
color: 0x2060b0,
|
|
transparent: true,
|
|
opacity: 0.14 + Math.random() * 0.1,
|
|
});
|
|
const line = new THREE.Line(geo, mat);
|
|
rayGroup.add(line);
|
|
|
|
const tipGeo = new THREE.SphereGeometry(0.014, 6, 6);
|
|
const tipMat = new THREE.MeshBasicMaterial({
|
|
color: 0x3080e0,
|
|
transparent: true,
|
|
opacity: 0.3 + Math.random() * 0.25,
|
|
});
|
|
const tip = new THREE.Mesh(tipGeo, tipMat);
|
|
tip.position.set(endX, endY, endZ);
|
|
rayGroup.add(tip);
|
|
|
|
rayData.push({ line, tip, phase: Math.random() * Math.PI * 2, speed: 0.3 + Math.random() * 0.5 });
|
|
}
|
|
|
|
// Swarm particles — more visible
|
|
const NUM_PARTICLES = 1200;
|
|
const particlePositions = new Float32Array(NUM_PARTICLES * 3);
|
|
const particleData = [];
|
|
|
|
for (let i = 0; i < NUM_PARTICLES; i++) {
|
|
const phi = Math.acos(2 * Math.random() - 1);
|
|
const theta = Math.random() * Math.PI * 2;
|
|
const r = 0.7 + Math.random() * 0.3;
|
|
particlePositions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
|
|
particlePositions[i * 3 + 1] = r * Math.cos(phi);
|
|
particlePositions[i * 3 + 2] = r * Math.sin(phi) * Math.sin(theta);
|
|
particleData.push({
|
|
phi, theta, r,
|
|
dTheta: (0.1 + Math.random() * 0.3) * (Math.random() > 0.5 ? 1 : -1),
|
|
dPhi: (Math.random() - 0.5) * 0.05,
|
|
wobblePhase: Math.random() * Math.PI * 2,
|
|
wobbleSpeed: 0.3 + Math.random() * 0.8,
|
|
});
|
|
}
|
|
|
|
const particleGeo = new THREE.BufferGeometry();
|
|
particleGeo.setAttribute('position', new THREE.BufferAttribute(particlePositions, 3));
|
|
|
|
const particleMat = new THREE.PointsMaterial({
|
|
color: 0x1540a0,
|
|
size: 0.025,
|
|
transparent: true,
|
|
opacity: 0.7,
|
|
sizeAttenuation: true,
|
|
blending: THREE.AdditiveBlending,
|
|
depthWrite: false,
|
|
});
|
|
const particles = new THREE.Points(particleGeo, particleMat);
|
|
globeGroup.add(particles);
|
|
|
|
// Nodes — stronger
|
|
const NUM_NODES = 40;
|
|
const nodeData = [];
|
|
|
|
for (let i = 0; i < NUM_NODES; i++) {
|
|
const phi = Math.acos(2 * Math.random() - 1);
|
|
const theta = Math.random() * Math.PI * 2;
|
|
const r = 0.35 + Math.random() * 0.45;
|
|
const geo = new THREE.SphereGeometry(0.018 + Math.random() * 0.015, 8, 8);
|
|
const mat = new THREE.MeshBasicMaterial({
|
|
color: 0x1a60c0,
|
|
transparent: true,
|
|
opacity: 0.4 + Math.random() * 0.25,
|
|
});
|
|
const mesh = new THREE.Mesh(geo, mat);
|
|
mesh.position.set(
|
|
r * Math.sin(phi) * Math.cos(theta),
|
|
r * Math.cos(phi),
|
|
r * Math.sin(phi) * Math.sin(theta)
|
|
);
|
|
globeGroup.add(mesh);
|
|
nodeData.push({
|
|
mesh, phi, theta, r,
|
|
speed: (0.15 + Math.random() * 0.3) * (Math.random() > 0.5 ? 1 : -1),
|
|
pulsePhase: Math.random() * Math.PI * 2,
|
|
});
|
|
}
|
|
|
|
// (ambient glow removed — clean background)
|
|
|
|
// ============================================================
|
|
// ANIMATION
|
|
// ============================================================
|
|
const clock = new THREE.Clock();
|
|
|
|
function animate() {
|
|
requestAnimationFrame(animate);
|
|
const dt = Math.min(clock.getDelta(), 0.05);
|
|
const t = clock.elapsedTime;
|
|
|
|
if (clickBoost > 0) {
|
|
clickBoost = Math.max(0, clickBoost - dt * 1.5);
|
|
targetSpin = 0.15 + clickBoost;
|
|
}
|
|
spinSpeed += (targetSpin - spinSpeed) * dt * 3;
|
|
|
|
globeGroup.rotation.y += spinSpeed * dt;
|
|
|
|
// Breathing core
|
|
const breath = Math.sin(t * 0.5) * 0.1 + 0.9;
|
|
core.scale.setScalar(breath);
|
|
coreGlow.scale.setScalar(breath * 1.2);
|
|
coreMat.opacity = 0.8 + Math.sin(t * 0.5) * 0.1;
|
|
|
|
// Swarm
|
|
const positions = particles.geometry.attributes.position.array;
|
|
for (let i = 0; i < NUM_PARTICLES; i++) {
|
|
const pd = particleData[i];
|
|
pd.theta += pd.dTheta * dt;
|
|
pd.phi += (pd.dPhi + Math.sin(t * pd.wobbleSpeed + pd.wobblePhase) * 0.02) * dt;
|
|
pd.phi = Math.max(0.1, Math.min(Math.PI - 0.1, pd.phi));
|
|
const r = pd.r + Math.sin(t * 0.3 + pd.wobblePhase) * 0.02;
|
|
positions[i * 3] = r * Math.sin(pd.phi) * Math.cos(pd.theta);
|
|
positions[i * 3 + 1] = r * Math.cos(pd.phi);
|
|
positions[i * 3 + 2] = r * Math.sin(pd.phi) * Math.sin(pd.theta);
|
|
}
|
|
particles.geometry.attributes.position.needsUpdate = true;
|
|
|
|
// Nodes
|
|
for (const nd of nodeData) {
|
|
nd.theta += nd.speed * dt;
|
|
const pulse = Math.sin(t * 1.5 + nd.pulsePhase) * 0.1 + 0.9;
|
|
nd.mesh.position.set(
|
|
nd.r * Math.sin(nd.phi) * Math.cos(nd.theta),
|
|
nd.r * Math.cos(nd.phi),
|
|
nd.r * Math.sin(nd.phi) * Math.sin(nd.theta)
|
|
);
|
|
nd.mesh.scale.setScalar(pulse);
|
|
nd.mesh.material.opacity = 0.2 + pulse * 0.2;
|
|
}
|
|
|
|
// Rays
|
|
for (const rd of rayData) {
|
|
const pulse = Math.sin(t * rd.speed + rd.phase) * 0.15 + 0.85;
|
|
rd.line.material.opacity = (0.06 + Math.sin(t * rd.speed + rd.phase) * 0.06) * pulse;
|
|
rd.tip.material.opacity = (0.15 + Math.sin(t * rd.speed + rd.phase) * 0.12) * pulse;
|
|
}
|
|
|
|
renderer.render(scene, camera);
|
|
}
|
|
|
|
animate();
|
|
|
|
window.addEventListener('resize', () => {
|
|
const w = container.clientWidth;
|
|
const h = container.clientHeight;
|
|
camera.aspect = w / h;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(w, h);
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|