web/public/index.html

1061 lines
34 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;
}
.notify-panel {
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
opacity: 0;
transition: opacity 500ms ease;
}
.notify-panel.visible {
opacity: 1;
}
.notify-form {
display: flex;
align-items: center;
gap: 10px;
}
.notify-input {
border: 0;
border-bottom: 1px solid #606264;
background: transparent;
color: #3a3d42;
font-family: 'IBM Plex Mono', 'Courier New', Courier, monospace;
font-size: 12px;
letter-spacing: 0.08em;
padding: 5px 3px;
min-width: 220px;
}
.notify-input:focus {
outline: none;
border-bottom-color: #2c2c2c;
}
/* 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">receive your designation token</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="notify-panel" class="notify-panel flow-hidden">
<button id="notify-open" class="flow-link localizable" data-i18n="notify_me" type="button">notify me</button>
<form id="notify-form" class="notify-form flow-hidden" autocomplete="on">
<input id="notify-email" class="notify-input localizable" data-i18n-placeholder="notify_placeholder" type="email" placeholder="email" required />
<button id="notify-submit" class="flow-link localizable" data-i18n="notify_submit" type="submit">save</button>
</form>
</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 notifyPanel = document.getElementById('notify-panel');
const notifyOpen = document.getElementById('notify-open');
const notifyForm = document.getElementById('notify-form');
const notifyEmail = document.getElementById('notify-email');
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 setFlowStatus(key, fallback, isError) {
flowStatus.textContent = t(key, fallback);
flowStatus.classList.toggle('error', !!isError);
showElement(flowStatus);
}
function clearFlowStatus() {
flowStatus.textContent = '';
flowStatus.classList.remove('error');
hideElement(flowStatus);
}
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);
}
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,
});
const ackState = {
code: verification.designation_code || intent.designation_code || null,
token: verification.display_token || intent.display_token || buildDisplayToken(verification.designation_code || intent.designation_code),
wallet: address,
chain_id: chainId,
};
saveAcknowledgement(ackState);
renderAcknowledged(ackState);
hideElement(continueAction);
hideElement(walletPanel);
showElement(notifyPanel);
setFlowStatus('wallet_success', 'Designation acknowledged.', false);
} catch (err) {
const message = err && err.message ? err.message : 'Wallet flow failed.';
setFlowStatus('wallet_failed', message, true);
}
}
async function submitNotifyEmail(event) {
event.preventDefault();
const email = notifyEmail.value.trim();
if (!email) return;
const ack = getStoredAcknowledgement();
try {
await postJSON('/secret/notify', {
email,
designation_code: ack && ack.code ? ack.code : null,
designation_token: ack && ack.token ? ack.token : null,
wallet: ack && ack.wallet ? ack.wallet : null,
locale: localStorage.getItem('edut_lang') || 'en',
});
setFlowStatus('notify_saved', 'Notification saved.', false);
notifyForm.classList.add('flow-hidden');
} catch (err) {
setFlowStatus('notify_failed', 'Could not save notification preference.', 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);
notifyOpen.addEventListener('click', () => {
notifyForm.classList.toggle('flow-hidden');
});
notifyForm.addEventListener('submit', submitNotifyEmail);
// 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>