Compare commits

...

1 Commits

Author SHA1 Message Date
kimi
e76b3eee89 feat: PWA manifest + service worker for offline and install support
Add full Progressive Web App support to The Nexus:
- manifest.json: Web app manifest with Nexus branding, icons, and PWA config
- sw.js: Service worker with cache-first strategy for local assets and
  stale-while-revalidate for CDN resources (Three.js, fonts)
- offline.html: Styled offline fallback page with auto-reconnect
- icons/: SVG icons (standard + maskable) with crystalline Nexus design
- index.html: Add manifest link, theme colors, Apple PWA meta tags,
  and service worker registration script

Features:
- Works offline after first visit
- Installable to home screen on mobile and desktop
- Graceful degradation when offline
- Auto-refresh when connection restored

Fixes #14
2026-03-23 23:52:07 -04:00
6 changed files with 640 additions and 0 deletions

60
icons/nexus-icon.svg Normal file
View File

@@ -0,0 +1,60 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4af0c0;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0a1628;stop-opacity:1" />
</linearGradient>
<filter id="glow">
<feGaussianBlur stdDeviation="4" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- Background -->
<rect width="512" height="512" fill="#0a1628"/>
<!-- Outer glow circle -->
<circle cx="256" cy="256" r="200" fill="none" stroke="#4af0c0" stroke-width="2" opacity="0.3"/>
<circle cx="256" cy="256" r="180" fill="none" stroke="#4af0c0" stroke-width="1" opacity="0.2"/>
<!-- Icosahedron / Crystal shape -->
<g transform="translate(256, 256)" filter="url(#glow)">
<!-- Main crystal body -->
<path d="M0,-120 L103.9,-60 L103.9,60 L0,120 L-103.9,60 L-103.9,-60 Z"
fill="none" stroke="#4af0c0" stroke-width="3" opacity="0.9"/>
<!-- Inner geometric lines -->
<path d="M0,-120 L0,120 M-103.9,-60 L103.9,60 M103.9,-60 L-103.9,60"
fill="none" stroke="#4af0c0" stroke-width="2" opacity="0.6"/>
<!-- Center point -->
<circle cx="0" cy="0" r="15" fill="#4af0c0" opacity="0.8"/>
<!-- Top crystal point -->
<path d="M0,-120 L0,-150 L15,-120 Z" fill="#4af0c0" opacity="0.7"/>
<path d="M0,-120 L0,-150 L-15,-120 Z" fill="#2dd4a8" opacity="0.7"/>
<!-- Bottom crystal point -->
<path d="M0,120 L0,150 L15,120 Z" fill="#4af0c0" opacity="0.7"/>
<path d="M0,120 L0,150 L-15,120 Z" fill="#2dd4a8" opacity="0.7"/>
<!-- Side crystal points -->
<path d="M103.9,-60 L130,-45 L103.9,-30 Z" fill="#4af0c0" opacity="0.6"/>
<path d="M103.9,60 L130,45 L103.9,30 Z" fill="#4af0c0" opacity="0.6"/>
<path d="M-103.9,-60 L-130,-45 L-103.9,-30 Z" fill="#4af0c0" opacity="0.6"/>
<path d="M-103.9,60 L-130,45 L-103.9,30 Z" fill="#4af0c0" opacity="0.6"/>
</g>
<!-- Small orbiting particles -->
<circle cx="380" cy="150" r="6" fill="#4af0c0" opacity="0.8">
<animate attributeName="opacity" values="0.8;0.3;0.8" dur="2s" repeatCount="indefinite"/>
</circle>
<circle cx="130" cy="380" r="4" fill="#4af0c0" opacity="0.6">
<animate attributeName="opacity" values="0.6;0.2;0.6" dur="3s" repeatCount="indefinite"/>
</circle>
<circle cx="400" cy="320" r="5" fill="#4af0c0" opacity="0.7">
<animate attributeName="opacity" values="0.7;0.3;0.7" dur="2.5s" repeatCount="indefinite"/>
</circle>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

32
icons/nexus-maskable.svg Normal file
View File

@@ -0,0 +1,32 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0d1f35;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0a1628;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background with safe zone for maskable icons -->
<rect width="512" height="512" fill="url(#bgGrad)"/>
<!-- Main icon content within safe zone (center 70% = ~358px) -->
<g transform="translate(256, 256)">
<!-- Icosahedron outline -->
<path d="M0,-100 L86.6,-50 L86.6,50 L0,100 L-86.6,50 L-86.6,-50 Z"
fill="none" stroke="#4af0c0" stroke-width="4"/>
<!-- Inner star pattern -->
<path d="M0,-100 L0,100 M-86.6,-50 L86.6,50 M86.6,-50 L-86.6,50"
fill="none" stroke="#4af0c0" stroke-width="3" opacity="0.7"/>
<!-- Center crystal -->
<circle cx="0" cy="0" r="20" fill="#4af0c0"/>
<!-- Corner accents -->
<circle cx="0" cy="-100" r="8" fill="#4af0c0"/>
<circle cx="86.6" cy="-50" r="8" fill="#4af0c0"/>
<circle cx="86.6" cy="50" r="8" fill="#4af0c0"/>
<circle cx="0" cy="100" r="8" fill="#4af0c0"/>
<circle cx="-86.6" cy="50" r="8" fill="#4af0c0"/>
<circle cx="-86.6" cy="-50" r="8" fill="#4af0c0"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -5,14 +5,38 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Timmy's Nexus</title>
<meta name="description" content="A sovereign 3D world">
<!-- Open Graph -->
<meta property="og:title" content="Timmy's Nexus">
<meta property="og:description" content="A sovereign 3D world">
<meta property="og:image" content="https://example.com/og-image.png">
<meta property="og:type" content="website">
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Timmy's Nexus">
<meta name="twitter:description" content="A sovereign 3D world">
<meta name="twitter:image" content="https://example.com/og-image.png">
<!-- PWA: Web App Manifest -->
<link rel="manifest" href="/manifest.json">
<!-- PWA: Theme Color -->
<meta name="theme-color" content="#4af0c0">
<meta name="background-color" content="#0a1628">
<!-- PWA: Apple iOS Support -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="The Nexus">
<link rel="apple-touch-icon" href="/icons/nexus-icon.svg">
<!-- PWA: Microsoft Windows -->
<meta name="msapplication-TileColor" content="#0a1628">
<meta name="msapplication-config" content="none">
<!-- Styles -->
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- ... existing content ... -->
@@ -26,5 +50,52 @@
</div>
<!-- ... existing content ... -->
<!-- Application Script -->
<script src="app.js"></script>
<!-- PWA: Service Worker Registration -->
<script>
(function() {
'use strict';
// Only register service worker in production (not in development with file://)
if ('serviceWorker' in navigator && window.location.protocol === 'https:' || window.location.hostname === 'localhost') {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js')
.then(function(registration) {
console.log('[Nexus] Service Worker registered:', registration.scope);
// Handle updates
registration.addEventListener('updatefound', function() {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', function() {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New version available
console.log('[Nexus] New version available, refreshing...');
// Optionally show update notification to user
if (confirm('A new version of The Nexus is available. Refresh to update?')) {
window.location.reload();
}
}
});
});
})
.catch(function(error) {
console.log('[Nexus] Service Worker registration failed:', error);
});
});
// Listen for messages from service worker
navigator.serviceWorker.addEventListener('message', function(event) {
if (event.data && event.data.type === 'OFFLINE_READY') {
console.log('[Nexus] App is ready for offline use');
}
});
} else {
console.log('[Nexus] Service Worker not supported or not in secure context');
}
})();
</script>
</body>
</html>

61
manifest.json Normal file
View File

@@ -0,0 +1,61 @@
{
"name": "The Nexus",
"short_name": "Nexus",
"description": "Timmy's sovereign 3D world — a Three.js environment serving as the central hub for all portals",
"start_url": "/",
"display": "standalone",
"display_override": ["fullscreen", "minimal-ui"],
"orientation": "any",
"background_color": "#0a1628",
"theme_color": "#4af0c0",
"categories": ["entertainment", "games"],
"lang": "en",
"dir": "ltr",
"scope": "/",
"icons": [
{
"src": "icons/nexus-icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "icons/nexus-maskable.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "maskable"
}
],
"screenshots": [
{
"src": "screenshots/nexus-wide.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide",
"label": "The Nexus 3D environment"
},
{
"src": "screenshots/nexus-narrow.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "narrow",
"label": "The Nexus on mobile"
}
],
"shortcuts": [
{
"name": "Enter Nexus",
"short_name": "Enter",
"description": "Jump directly into the Nexus world",
"url": "/?action=enter",
"icons": [
{
"src": "icons/nexus-icon.svg",
"sizes": "any"
}
]
}
],
"related_applications": [],
"prefer_related_applications": false
}

198
offline.html Normal file
View File

@@ -0,0 +1,198 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline — The Nexus</title>
<meta name="description" content="The Nexus is currently offline">
<style>
:root {
--color-bg: #0a1628;
--color-bg-secondary: #0d1f35;
--color-primary: #4af0c0;
--color-primary-dim: #2dd4a8;
--color-text: #e6f1ff;
--color-text-muted: #8b9bb4;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: var(--color-bg);
color: var(--color-text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem;
background: radial-gradient(ellipse at center, var(--color-bg-secondary) 0%, var(--color-bg) 70%);
}
.container {
max-width: 480px;
}
.icon {
width: 120px;
height: 120px;
margin-bottom: 2rem;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.7; transform: scale(0.95); }
}
h1 {
font-size: 2rem;
font-weight: 300;
margin-bottom: 1rem;
letter-spacing: 0.05em;
}
.nexus-title {
color: var(--color-primary);
font-weight: 500;
}
p {
color: var(--color-text-muted);
line-height: 1.6;
margin-bottom: 2rem;
}
.status {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: rgba(74, 240, 192, 0.1);
border: 1px solid rgba(74, 240, 192, 0.3);
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 0.875rem;
color: var(--color-primary);
margin-bottom: 1.5rem;
}
.status::before {
content: '';
width: 8px;
height: 8px;
background: var(--color-primary);
border-radius: 50%;
animation: blink 1.5s ease-in-out infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: var(--color-primary);
color: var(--color-bg);
border: none;
padding: 0.875rem 1.5rem;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
}
.btn:hover {
background: var(--color-primary-dim);
transform: translateY(-1px);
}
.btn:active {
transform: translateY(0);
}
.hint {
margin-top: 2rem;
font-size: 0.75rem;
color: var(--color-text-muted);
}
/* Crystal animation */
.crystal {
fill: none;
stroke: var(--color-primary);
stroke-width: 2;
}
.crystal-center {
fill: var(--color-primary);
}
</style>
</head>
<body>
<div class="container">
<svg class="icon" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<!-- Crystal icosahedron representation -->
<path class="crystal" d="M50,15 L84.6,42.5 L84.6,72.5 L50,85 L15.4,72.5 L15.4,42.5 Z" opacity="0.8"/>
<path class="crystal" d="M50,15 L50,85 M15.4,42.5 L84.6,72.5 M84.6,42.5 L15.4,72.5" opacity="0.5"/>
<circle class="crystal-center" cx="50" cy="55" r="8" opacity="0.9"/>
<!-- Offline indicator -->
<circle cx="75" cy="25" r="12" fill="#ff6b6b" opacity="0.9"/>
<path d="M69,25 L81,25" stroke="white" stroke-width="2"/>
</svg>
<h1>The <span class="nexus-title">Nexus</span> is Dormant</h1>
<div class="status">
<span>You're offline</span>
</div>
<p>
The crystalline pathways cannot form without a connection to the sovereign network.
Check your connection and try again to enter the 3D realm.
</p>
<button class="btn" onclick="window.location.reload()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"></polyline>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
</svg>
Reconnect
</button>
<p class="hint">
Core assets are cached for offline use. Some features may be limited without connectivity.
</p>
</div>
<script>
// Auto-retry when connection comes back
window.addEventListener('online', () => {
window.location.href = '/';
});
// Check if we're actually back online
setInterval(() => {
if (navigator.onLine) {
fetch('/', { method: 'HEAD', cache: 'no-store' })
.then(() => {
window.location.href = '/';
})
.catch(() => {
// Still unreachable, stay on offline page
});
}
}, 5000);
</script>
</body>
</html>

218
sw.js Normal file
View File

@@ -0,0 +1,218 @@
/**
* The Nexus Service Worker
* Provides offline capability and home screen install support
* Strategy: Cache-first for local assets, stale-while-revalidate for CDN
*/
const CACHE_VERSION = 'nexus-v1';
const STATIC_CACHE = `${CACHE_VERSION}-static`;
const CDN_CACHE = `${CACHE_VERSION}-cdn`;
const OFFLINE_PAGE = '/offline.html';
// Core local assets that must be cached
const CORE_ASSETS = [
'/',
'/index.html',
'/style.css',
'/app.js',
'/manifest.json',
'/icons/nexus-icon.svg',
'/icons/nexus-maskable.svg',
OFFLINE_PAGE
];
// CDN resources that benefit from caching but can be stale
const CDN_PATTERNS = [
/^https:\/\/unpkg\.com/,
/^https:\/\/cdn\.jsdelivr\.net/,
/^https:\/\/fonts\.googleapis\.com/,
/^https:\/\/fonts\.gstatic\.com/,
/^https:\/\/cdn\.threejs\.org/
];
/**
* Check if a URL matches any CDN pattern
*/
function isCdnResource(url) {
return CDN_PATTERNS.some(pattern => pattern.test(url));
}
/**
* Install event - cache core assets
*/
self.addEventListener('install', (event) => {
console.log('[Nexus SW] Installing...');
event.waitUntil(
caches.open(STATIC_CACHE)
.then(cache => {
console.log('[Nexus SW] Caching core assets');
return cache.addAll(CORE_ASSETS);
})
.then(() => {
console.log('[Nexus SW] Core assets cached');
return self.skipWaiting();
})
.catch(err => {
console.error('[Nexus SW] Cache failed:', err);
})
);
});
/**
* Activate event - clean up old caches
*/
self.addEventListener('activate', (event) => {
console.log('[Nexus SW] Activating...');
event.waitUntil(
caches.keys()
.then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name.startsWith('nexus-') && name !== STATIC_CACHE && name !== CDN_CACHE)
.map(name => {
console.log('[Nexus SW] Deleting old cache:', name);
return caches.delete(name);
})
);
})
.then(() => {
console.log('[Nexus SW] Activated');
return self.clients.claim();
})
);
});
/**
* Fetch event - handle requests with appropriate strategy
*/
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== 'GET') {
return;
}
// Skip cross-origin requests that aren't CDN resources
if (url.origin !== self.location.origin && !isCdnResource(url.href)) {
return;
}
// Handle CDN resources with stale-while-revalidate
if (isCdnResource(url.href)) {
event.respondWith(handleCdnRequest(request));
return;
}
// Handle local assets with cache-first strategy
event.respondWith(handleLocalRequest(request));
});
/**
* Cache-first strategy for local assets
* Fastest response, updates cache in background
*/
async function handleLocalRequest(request) {
try {
const cache = await caches.open(STATIC_CACHE);
const cached = await cache.match(request);
// Return cached version immediately if available
if (cached) {
// Revalidate in background for next time
fetch(request)
.then(response => {
if (response.ok) {
cache.put(request, response.clone());
}
})
.catch(() => {
// Network failed, cached version is already being used
});
return cached;
}
// Not in cache - fetch from network
const response = await fetch(request);
if (response.ok) {
cache.put(request, response.clone());
}
return response;
} catch (error) {
console.error('[Nexus SW] Local request failed:', error);
// Return offline page for navigation requests
if (request.mode === 'navigate') {
const cache = await caches.open(STATIC_CACHE);
const offlinePage = await cache.match(OFFLINE_PAGE);
if (offlinePage) {
return offlinePage;
}
}
throw error;
}
}
/**
* Stale-while-revalidate strategy for CDN resources
* Serves stale content while updating in background
*/
async function handleCdnRequest(request) {
const cache = await caches.open(CDN_CACHE);
const cached = await cache.match(request);
// Always try to fetch fresh version
const fetchPromise = fetch(request)
.then(response => {
if (response.ok) {
cache.put(request, response.clone());
}
return response;
})
.catch(error => {
console.error('[Nexus SW] CDN fetch failed:', error);
throw error;
});
// Return cached version immediately if available, otherwise wait for network
if (cached) {
// Return stale but revalidate for next time
fetchPromise.catch(() => {}); // Swallow errors, we have cached version
return cached;
}
// No cache - must wait for network
return fetchPromise;
}
/**
* Message handler for runtime cache updates
*/
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
if (event.data && event.data.type === 'GET_VERSION') {
event.ports[0].postMessage({ version: CACHE_VERSION });
}
});
/**
* Background sync for deferred actions (future enhancement)
*/
self.addEventListener('sync', (event) => {
if (event.tag === 'nexus-sync') {
console.log('[Nexus SW] Background sync triggered');
// Future: sync chat messages, state updates, etc.
}
});
console.log('[Nexus SW] Service worker loaded');