Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
e3f474662e feat: add PWA manifest + service worker for offline + home screen install
- manifest.json with icons, theme colors, standalone display mode
- sw.js: cache-first service worker caching local assets and Three.js CDN
  modules, fonts; graceful offline fallback with cached index.html
- Offline banner (visible when navigator.onLine === false)
- iOS/Android home screen meta tags (apple-mobile-web-app-capable etc.)
- 192x192 and 512x512 PNG icons with nexus sigil design

Fixes #14
2026-03-23 21:23:15 -04:00
8 changed files with 106 additions and 166 deletions

BIN
icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

BIN
icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View File

@@ -1,30 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="bg-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#050510"/>
<stop offset="100%" stop-color="#0a0f28"/>
</linearGradient>
<linearGradient id="sigil-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#4af0c0"/>
<stop offset="100%" stop-color="#7b5cff"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="512" height="512" rx="96" fill="url(#bg-grad)"/>
<!-- Outer ring -->
<circle cx="256" cy="256" r="220" fill="none" stroke="url(#sigil-grad)" stroke-width="4" opacity="0.5"/>
<!-- Inner ring -->
<circle cx="256" cy="256" r="180" fill="none" stroke="url(#sigil-grad)" stroke-width="2" opacity="0.35"/>
<!-- Triangle -->
<polygon points="256,80 420,360 92,360" fill="none" stroke="#4af0c0" stroke-width="5" opacity="0.75"/>
<!-- Inner triangle (inverted) -->
<polygon points="256,432 92,152 420,152" fill="none" stroke="#7b5cff" stroke-width="3" opacity="0.45"/>
<!-- Core crystal -->
<circle cx="256" cy="256" r="32" fill="#4af0c0" opacity="0.9"/>
<circle cx="256" cy="256" r="20" fill="#7b5cff" opacity="0.8"/>
<circle cx="256" cy="256" r="10" fill="#ffffff" opacity="0.95"/>
<!-- Corner accent marks -->
<circle cx="256" cy="36" r="5" fill="#4af0c0" opacity="0.8"/>
<circle cx="476" cy="360" r="5" fill="#4af0c0" opacity="0.8"/>
<circle cx="36" cy="360" r="5" fill="#4af0c0" opacity="0.8"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,22 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="bg-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#050510"/>
<stop offset="100%" stop-color="#0a0f28"/>
</linearGradient>
<linearGradient id="sigil-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#4af0c0"/>
<stop offset="100%" stop-color="#7b5cff"/>
</linearGradient>
</defs>
<!-- Full bleed background for maskable -->
<rect width="512" height="512" fill="url(#bg-grad)"/>
<!-- Safe zone content (within 80% = 204px padding 51px each side) -->
<circle cx="256" cy="256" r="195" fill="none" stroke="url(#sigil-grad)" stroke-width="3" opacity="0.5"/>
<circle cx="256" cy="256" r="160" fill="none" stroke="url(#sigil-grad)" stroke-width="2" opacity="0.3"/>
<polygon points="256,100 392,340 120,340" fill="none" stroke="#4af0c0" stroke-width="5" opacity="0.75"/>
<polygon points="256,412 120,172 392,172" fill="none" stroke="#7b5cff" stroke-width="3" opacity="0.4"/>
<circle cx="256" cy="256" r="30" fill="#4af0c0" opacity="0.9"/>
<circle cx="256" cy="256" r="18" fill="#7b5cff" opacity="0.85"/>
<circle cx="256" cy="256" r="9" fill="#ffffff" opacity="0.95"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -19,14 +19,13 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Nexus — Timmy's Sovereign Home</title>
<!-- PWA -->
<link rel="manifest" href="/manifest.json">
<link rel="manifest" href="./manifest.json">
<meta name="theme-color" content="#4af0c0">
<meta name="mobile-web-app-capable" content="yes">
<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">
<link rel="apple-touch-icon" href="./icons/icon-192.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Orbitron:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
@@ -41,6 +40,11 @@
</script>
</head>
<body>
<!-- Offline Banner -->
<div id="offline-banner" role="status" aria-live="polite">
◈ NEXUS OFFLINE MODE — Running on cached assets
</div>
<!-- Loading Screen -->
<div id="loading-screen">
<div class="loader-content">
@@ -127,10 +131,20 @@
<script type="module" src="./app.js"></script>
<script>
// Offline/online detection
const offlineBanner = document.getElementById('offline-banner');
function updateOnlineStatus() {
offlineBanner.classList.toggle('visible', !navigator.onLine);
}
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
updateOnlineStatus();
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.catch((err) => console.warn('[Nexus SW] Registration failed:', err));
navigator.serviceWorker.register('./sw.js')
.then(reg => console.log('[Nexus SW] Registered:', reg.scope))
.catch(err => console.warn('[Nexus SW] Registration failed:', err));
});
}
</script>

View File

@@ -1,27 +1,25 @@
{
"name": "The Nexus — Timmy's Sovereign Home",
"short_name": "The Nexus",
"description": "Timmy's sovereign 3D space — a cosmic hub existing outside time, accessible from anywhere.",
"description": "Timmy's sovereign 3D space. A crystalline hub outside time.",
"start_url": "/",
"display": "standalone",
"orientation": "any",
"orientation": "landscape",
"background_color": "#050510",
"theme_color": "#4af0c0",
"categories": ["entertainment", "games"],
"categories": ["games", "entertainment"],
"icons": [
{
"src": "/icons/nexus-icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/nexus-maskable.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "maskable"
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"screenshots": [],
"prefer_related_applications": false
]
}

View File

@@ -359,3 +359,24 @@ canvas#nexus-canvas {
display: none;
}
}
/* === OFFLINE INDICATOR === */
#offline-banner {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
padding: 6px 16px;
background: rgba(255, 170, 34, 0.15);
border-bottom: 1px solid var(--color-warning);
color: var(--color-warning);
font-family: var(--font-body);
font-size: var(--text-xs);
text-align: center;
letter-spacing: 0.08em;
}
#offline-banner.visible {
display: block;
}

149
sw.js
View File

@@ -1,153 +1,112 @@
// ═══════════════════════════════════════════
// THE NEXUS — Service Worker
// Offline support + asset caching
// ═══════════════════════════════════════════
// ◈ The Nexus — Service Worker
// Offline-first caching for sovereign space
const CACHE_VERSION = 'nexus-v1';
const STATIC_CACHE = `${CACHE_VERSION}-static`;
const CDN_CACHE = `${CACHE_VERSION}-cdn`;
// Core local assets — always cached on install
// Core local assets — always cache
const STATIC_ASSETS = [
'/',
'/index.html',
'/style.css',
'/app.js',
'/manifest.json',
'/icons/nexus-icon.svg',
'/icons/nexus-maskable.svg',
'/icons/icon-192.png',
'/icons/icon-512.png',
];
// CDN assets — cached on first use (Three.js + fonts)
const CDN_HOSTS = [
'cdn.jsdelivr.net',
'fonts.googleapis.com',
'fonts.gstatic.com',
// CDN assets for Three.js — cache on first fetch
const CDN_ORIGINS = [
'https://cdn.jsdelivr.net',
'https://fonts.googleapis.com',
'https://fonts.gstatic.com',
];
// ═══ INSTALL ═══
self.addEventListener('install', (event) => {
// ═══ INSTALL — pre-cache static assets ═══
self.addEventListener('install', event => {
event.waitUntil(
caches.open(STATIC_CACHE)
.then((cache) => cache.addAll(STATIC_ASSETS))
.then(cache => cache.addAll(STATIC_ASSETS))
.then(() => self.skipWaiting())
);
});
// ═══ ACTIVATE ═══
self.addEventListener('activate', (event) => {
// ═══ ACTIVATE — clean up old caches ═══
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(
.then(keys => Promise.all(
keys
.filter((key) => key.startsWith('nexus-') && key !== STATIC_CACHE && key !== CDN_CACHE)
.map((key) => caches.delete(key))
.filter(key => key.startsWith('nexus-') && key !== STATIC_CACHE && key !== CDN_CACHE)
.map(key => caches.delete(key))
))
.then(() => self.clients.claim())
);
});
// ═══ FETCH ═══
self.addEventListener('fetch', (event) => {
// ═══ FETCH — serve from cache, fall back to network ═══
self.addEventListener('fetch', event => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET and chrome-extension requests
if (request.method !== 'GET' || url.protocol === 'chrome-extension:') {
// Skip non-GET requests
if (request.method !== 'GET') return;
// CDN resources: cache-first with network fallback
if (CDN_ORIGINS.some(origin => request.url.startsWith(origin))) {
event.respondWith(cdnFirst(request));
return;
}
// CDN assets — stale-while-revalidate
if (CDN_HOSTS.includes(url.hostname)) {
event.respondWith(staleWhileRevalidate(CDN_CACHE, request));
return;
}
// Local static assets — cache-first
// Same-origin static assets: cache-first
if (url.origin === self.location.origin) {
event.respondWith(cacheFirst(STATIC_CACHE, request));
event.respondWith(staticFirst(request));
return;
}
});
// ═══ STRATEGIES ═══
// Cache-first for CDN (Three.js modules, fonts)
async function cdnFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
/**
* Cache-first: serve from cache, fall back to network, update cache.
* Best for versioned static assets.
*/
async function cacheFirst(cacheName, request) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
if (cached) {
return cached;
}
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CDN_CACHE);
cache.put(request, response.clone());
}
return response;
} catch {
// Return offline fallback for navigation requests
if (request.mode === 'navigate') {
const fallback = await cache.match('/index.html');
if (fallback) return fallback;
}
return offlineResponse(request);
// Offline and not cached — return a minimal error response
return new Response('/* offline */', {
headers: { 'Content-Type': 'text/plain' },
});
}
}
/**
* Stale-while-revalidate: serve cached immediately, refresh in background.
* Best for CDN assets where freshness is nice but not critical.
*/
async function staleWhileRevalidate(cacheName, request) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
// Cache-first for local static assets
async function staticFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const fetchPromise = fetch(request).then((response) => {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(STATIC_CACHE);
cache.put(request, response.clone());
}
return response;
}).catch(() => null);
return cached || fetchPromise || offlineResponse(request);
}
/**
* Minimal offline response for requests that have no cached version.
*/
function offlineResponse(request) {
if (request.destination === 'document' || request.mode === 'navigate') {
return new Response(
`<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Nexus — Offline</title>
<style>
body { margin: 0; background: #050510; color: #4af0c0; font-family: monospace;
display: flex; align-items: center; justify-content: center; height: 100vh; text-align: center; }
h1 { font-size: 2rem; letter-spacing: 0.3em; margin-bottom: 1rem; }
p { color: #7b5cff; letter-spacing: 0.1em; }
.sigil { font-size: 4rem; margin-bottom: 2rem; }
</style>
</head>
<body>
<div>
<div class="sigil">◈</div>
<h1>THE NEXUS</h1>
<p>Sovereign space is offline.</p>
<p style="margin-top:1rem;opacity:0.5;">Reconnect to re-enter.</p>
</div>
</body>
</html>`,
{ status: 503, headers: { 'Content-Type': 'text/html; charset=utf-8' } }
);
} catch {
// Offline fallback: serve index.html for navigation requests
if (request.mode === 'navigate') {
const fallback = await caches.match('/index.html');
if (fallback) return fallback;
}
return new Response('Nexus offline — cached assets not found.', {
status: 503,
headers: { 'Content-Type': 'text/plain' },
});
}
return new Response('', { status: 503, statusText: 'Service Unavailable' });
}