web/public/index.html
Joshua c80b1db18b
Some checks are pending
check / secretapi (push) Waiting to run
W0: add deterministic quote cost envelope and docs sync
2026-02-19 18:02:30 -08:00

1229 lines
40 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">activate your EDUT ID</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">EDUT ID 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,
sessionToken: '',
sessionExpiresAt: '',
sessionWallet: '',
sessionRefreshInFlight: 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)) {
flowState.sessionToken = typeof parsed.session_token === 'string' ? parsed.session_token : '';
flowState.sessionExpiresAt = typeof parsed.session_expires_at === 'string' ? parsed.session_expires_at : '';
flowState.sessionWallet = typeof parsed.wallet === 'string' ? parsed.wallet : '';
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 envelope = (quote.cost_envelope && typeof quote.cost_envelope === 'object') ? quote.cost_envelope : null;
const currency = envelope && envelope.checkout_currency
? String(envelope.checkout_currency)
: (quote.currency ? String(quote.currency) : '');
let amount = null;
if (envelope && envelope.checkout_total !== undefined && envelope.checkout_total !== null) {
amount = String(envelope.checkout_total);
} else 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;
}
function clearFlowSession(reason) {
flowState.sessionToken = '';
flowState.sessionExpiresAt = '';
flowState.sessionWallet = '';
if (reason) {
console.warn('wallet session cleared:', reason);
}
}
function captureFlowSessionHeaders(res) {
const token = String(res.headers.get('x-edut-session') || '').trim();
if (token) {
flowState.sessionToken = token;
}
const expiresAt = String(res.headers.get('x-edut-session-expires-at') || '').trim();
if (expiresAt) {
flowState.sessionExpiresAt = expiresAt;
}
}
function parseApiError(text, status) {
if (!text) {
return { message: 'HTTP ' + status, code: '' };
}
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed === 'object') {
return {
message: String(parsed.error || ('HTTP ' + status)),
code: String(parsed.code || ''),
};
}
} catch (err) {
// fall back to raw text
}
return { message: text, code: '' };
}
function isTerminalSessionCode(code) {
const normalized = String(code || '').toLowerCase();
return normalized === 'wallet_session_invalid' ||
normalized === 'wallet_session_expired' ||
normalized === 'wallet_session_revoked' ||
normalized === 'wallet_session_mismatch';
}
async function maybeRefreshFlowSession(wallet, requestUrl) {
if (!flowState.sessionToken || !wallet || !flowState.sessionExpiresAt) {
return;
}
if (String(requestUrl || '').indexOf('/secret/wallet/session/') === 0) {
return;
}
const expiresAt = Date.parse(flowState.sessionExpiresAt);
if (!Number.isFinite(expiresAt)) {
return;
}
if ((expiresAt - Date.now()) > (5 * 60 * 1000)) {
return;
}
if (flowState.sessionRefreshInFlight) {
await flowState.sessionRefreshInFlight;
return;
}
flowState.sessionRefreshInFlight = postJSON(
'/secret/wallet/session/refresh',
{ wallet },
{ wallet, skipSessionRefresh: true },
).then(function (out) {
if (out && typeof out.session_token === 'string') {
flowState.sessionToken = out.session_token;
}
if (out && typeof out.session_expires_at === 'string') {
flowState.sessionExpiresAt = out.session_expires_at;
}
}).finally(function () {
flowState.sessionRefreshInFlight = null;
});
await flowState.sessionRefreshInFlight;
}
async function postJSON(url, payload, options) {
const opts = options || {};
if (!opts.skipSessionRefresh) {
await maybeRefreshFlowSession(opts.wallet || flowState.sessionWallet || '', url);
}
const headers = {
'Content-Type': 'application/json',
};
if (flowState.sessionToken) {
headers['X-Edut-Session'] = flowState.sessionToken;
headers.Authorization = 'Bearer ' + flowState.sessionToken;
}
const res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(payload),
});
captureFlowSessionHeaders(res);
const text = await res.text();
if (!res.ok) {
const detail = parseApiError(text, res.status);
if (isTerminalSessionCode(detail.code)) {
clearFlowSession(detail.code);
}
throw new Error(detail.message);
}
if (!text) return {};
return JSON.parse(text);
}
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];
if (flowState.sessionWallet && flowState.sessionWallet.toLowerCase() !== address.toLowerCase()) {
clearFlowSession('wallet_changed');
}
flowState.sessionWallet = address;
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,
}, { wallet: address });
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,
}, { wallet: address });
flowState.sessionToken = verification.session_token || '';
flowState.sessionExpiresAt = verification.session_expires_at || '';
flowState.sessionWallet = address;
setFlowStatus('membership_quoting', 'Preparing EDUT ID activation...', false);
const quote = await postJSON('/secret/id/quote', {
designation_code: verification.designation_code || intent.designation_code || null,
address,
chain_id: chainId,
}, { wallet: address });
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('EDUT ID quote is missing destination contract.');
}
const quoteDisplay = formatQuoteDisplay(quote);
const mintPrompt = t('membership_minting', 'Confirm EDUT ID activation in your wallet...');
if (quoteDisplay) {
setFlowStatusMessage(mintPrompt + ' (' + quoteDisplay + ')', false);
} else {
setFlowStatus('membership_minting', 'Confirm EDUT ID activation in your wallet...', false);
}
const txHash = await window.ethereum.request({
method: 'eth_sendTransaction',
params: [txParams],
});
setFlowStatus('membership_confirming', 'Confirming EDUT ID on-chain...', false);
const confirmation = await postJSON('/secret/id/confirm', {
designation_code: verification.designation_code || intent.designation_code || null,
quote_id: quote.quote_id || null,
tx_hash: txHash,
address,
chain_id: chainId,
}, { wallet: address });
if (confirmation.status !== 'membership_active') {
throw new Error('EDUT ID 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,
session_token: flowState.sessionToken || '',
session_expires_at: flowState.sessionExpiresAt || '',
};
saveAcknowledgement(ackState);
renderAcknowledged(ackState);
showPostMintPanel();
setFlowStatus('membership_active', 'EDUT ID 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>