diff --git a/apps/web/public/icons/apple-touch-icon.png b/apps/web/public/icons/apple-touch-icon.png
new file mode 100644
index 00000000..a6e3f749
Binary files /dev/null and b/apps/web/public/icons/apple-touch-icon.png differ
diff --git a/apps/web/public/icons/generate-icons.js b/apps/web/public/icons/generate-icons.js
new file mode 100644
index 00000000..66beb73b
--- /dev/null
+++ b/apps/web/public/icons/generate-icons.js
@@ -0,0 +1,61 @@
+const fs = require('fs');
+const { createCanvas } = require('canvas');
+
+function generateIcon(size, filename) {
+ const canvas = createCanvas(size, size);
+ const ctx = canvas.getContext('2d');
+
+ // Background
+ ctx.fillStyle = '#0f172a';
+ ctx.beginPath();
+ ctx.roundRect(0, 0, size, size, size * 0.18);
+ ctx.fill();
+
+ // Gradient for sparkle
+ const gradient = ctx.createLinearGradient(0, 0, size, size);
+ gradient.addColorStop(0, '#22d3ee');
+ gradient.addColorStop(1, '#3b82f6');
+
+ // Draw sparkle
+ const cx = size / 2;
+ const cy = size / 2;
+ const s = size * 0.23; // sparkle size
+
+ ctx.fillStyle = gradient;
+ ctx.beginPath();
+ ctx.moveTo(cx, cy - s);
+ ctx.lineTo(cx + s * 0.12, cy - s * 0.12);
+ ctx.lineTo(cx + s, cy);
+ ctx.lineTo(cx + s * 0.12, cy + s * 0.12);
+ ctx.lineTo(cx, cy + s);
+ ctx.lineTo(cx - s * 0.12, cy + s * 0.12);
+ ctx.lineTo(cx - s, cy);
+ ctx.lineTo(cx - s * 0.12, cy - s * 0.12);
+ ctx.closePath();
+ ctx.fill();
+
+ // Small sparkles
+ ctx.fillStyle = '#22d3ee';
+ ctx.globalAlpha = 0.7;
+ ctx.beginPath();
+ ctx.arc(cx + size * 0.15, cy - size * 0.15, size * 0.023, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.fillStyle = '#3b82f6';
+ ctx.globalAlpha = 0.6;
+ ctx.beginPath();
+ ctx.arc(cx - size * 0.14, cy + size * 0.14, size * 0.015, 0, Math.PI * 2);
+ ctx.fill();
+
+ const buffer = canvas.toBuffer('image/png');
+ fs.writeFileSync(filename, buffer);
+ console.log(`Generated ${filename}`);
+}
+
+try {
+ generateIcon(192, 'icon-192x192.png');
+ generateIcon(512, 'icon-512x512.png');
+ generateIcon(180, 'apple-touch-icon.png');
+} catch (e) {
+ console.error('Error:', e.message);
+}
diff --git a/apps/web/public/icons/icon-192x192.png b/apps/web/public/icons/icon-192x192.png
new file mode 100644
index 00000000..a6e3f749
Binary files /dev/null and b/apps/web/public/icons/icon-192x192.png differ
diff --git a/apps/web/public/icons/icon-512x512.png b/apps/web/public/icons/icon-512x512.png
new file mode 100644
index 00000000..a6e3f749
Binary files /dev/null and b/apps/web/public/icons/icon-512x512.png differ
diff --git a/apps/web/public/icons/icon.svg b/apps/web/public/icons/icon.svg
new file mode 100644
index 00000000..be8ed133
--- /dev/null
+++ b/apps/web/public/icons/icon.svg
@@ -0,0 +1,16 @@
+
diff --git a/apps/web/public/manifest.json b/apps/web/public/manifest.json
new file mode 100644
index 00000000..516a27c2
--- /dev/null
+++ b/apps/web/public/manifest.json
@@ -0,0 +1,28 @@
+{
+ "name": "DAARION.city",
+ "short_name": "DAARION",
+ "description": "Децентралізована платформа для мікро-спільнот з AI-агентами",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "orientation": "portrait-primary",
+ "theme_color": "#0c4a6e",
+ "background_color": "#0f172a",
+ "icons": [
+ {
+ "src": "/icons/icon-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any maskable"
+ },
+ {
+ "src": "/icons/icon-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any maskable"
+ }
+ ],
+ "categories": ["social", "productivity"],
+ "lang": "uk"
+}
+
diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js
new file mode 100644
index 00000000..b2fb4df6
--- /dev/null
+++ b/apps/web/public/sw.js
@@ -0,0 +1,135 @@
+// DAARION Service Worker v1.0
+const CACHE_VERSION = 'v1';
+const STATIC_CACHE = `daarion-static-${CACHE_VERSION}`;
+const PAGES_CACHE = `daarion-pages-${CACHE_VERSION}`;
+
+// Files to precache
+const PRECACHE_URLS = [
+ '/',
+ '/city',
+ '/offline',
+ '/manifest.json',
+ '/icons/icon-192x192.png',
+ '/icons/icon-512x512.png'
+];
+
+// Install event - precache static assets
+self.addEventListener('install', (event) => {
+ console.log('[SW] Installing...');
+ event.waitUntil(
+ caches.open(STATIC_CACHE)
+ .then((cache) => {
+ console.log('[SW] Precaching app shell');
+ return cache.addAll(PRECACHE_URLS);
+ })
+ .then(() => self.skipWaiting())
+ );
+});
+
+// Activate event - clean old caches
+self.addEventListener('activate', (event) => {
+ console.log('[SW] Activating...');
+ event.waitUntil(
+ caches.keys().then((cacheNames) => {
+ return Promise.all(
+ cacheNames
+ .filter((name) => name.startsWith('daarion-') && name !== STATIC_CACHE && name !== PAGES_CACHE)
+ .map((name) => {
+ console.log('[SW] Deleting old cache:', name);
+ return caches.delete(name);
+ })
+ );
+ }).then(() => self.clients.claim())
+ );
+});
+
+// Fetch event - handle requests
+self.addEventListener('fetch', (event) => {
+ const { request } = event;
+ const url = new URL(request.url);
+
+ // Skip non-GET requests
+ if (request.method !== 'GET') return;
+
+ // Skip API and WebSocket requests
+ if (url.pathname.startsWith('/api/') ||
+ url.pathname.startsWith('/ws/') ||
+ url.pathname.startsWith('/_matrix/')) {
+ return;
+ }
+
+ // Static assets - Cache First
+ if (url.pathname.startsWith('/_next/static/') ||
+ url.pathname.match(/\.(js|css|png|jpg|jpeg|gif|svg|woff|woff2)$/)) {
+ event.respondWith(cacheFirst(request, STATIC_CACHE));
+ return;
+ }
+
+ // HTML pages - Network First with fallback
+ if (request.headers.get('accept')?.includes('text/html')) {
+ event.respondWith(networkFirstWithFallback(request, PAGES_CACHE));
+ return;
+ }
+
+ // Default - try network
+ event.respondWith(fetch(request));
+});
+
+// Cache First strategy
+async function cacheFirst(request, cacheName) {
+ const cachedResponse = await caches.match(request);
+ if (cachedResponse) {
+ // Update cache in background
+ fetch(request).then((response) => {
+ if (response.ok) {
+ caches.open(cacheName).then((cache) => cache.put(request, response));
+ }
+ }).catch(() => {});
+ return cachedResponse;
+ }
+
+ try {
+ const response = await fetch(request);
+ if (response.ok) {
+ const cache = await caches.open(cacheName);
+ cache.put(request, response.clone());
+ }
+ return response;
+ } catch (error) {
+ console.log('[SW] Cache first failed:', request.url);
+ throw error;
+ }
+}
+
+// Network First with fallback strategy
+async function networkFirstWithFallback(request, cacheName) {
+ try {
+ const response = await fetch(request);
+ if (response.ok) {
+ const cache = await caches.open(cacheName);
+ cache.put(request, response.clone());
+ }
+ return response;
+ } catch (error) {
+ console.log('[SW] Network failed, trying cache:', request.url);
+ const cachedResponse = await caches.match(request);
+ if (cachedResponse) {
+ return cachedResponse;
+ }
+
+ // Return offline page for navigation requests
+ const offlineResponse = await caches.match('/offline');
+ if (offlineResponse) {
+ return offlineResponse;
+ }
+
+ // Return a basic offline response
+ return new Response(
+ '
Ви офлайн
Перевірте підключення до інтернету
',
+ {
+ status: 503,
+ headers: { 'Content-Type': 'text/html; charset=utf-8' }
+ }
+ );
+ }
+}
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 856f7a70..89da0920 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -3,6 +3,7 @@ import { Inter } from 'next/font/google'
import './globals.css'
import { Navigation } from '@/components/Navigation'
import { AuthProvider } from '@/context/AuthContext'
+import { PWAProvider } from '@/components/PWAProvider'
const inter = Inter({
subsets: ['latin', 'cyrillic'],
@@ -15,6 +16,16 @@ export const metadata: Metadata = {
description: 'Децентралізована платформа для мікро-спільнот з AI-агентами',
keywords: ['DAARION', 'DAO', 'AI', 'agents', 'community', 'decentralized'],
authors: [{ name: 'DAARION Team' }],
+ manifest: '/manifest.json',
+ appleWebApp: {
+ capable: true,
+ statusBarStyle: 'black-translucent',
+ title: 'DAARION',
+ },
+ icons: {
+ icon: '/icons/icon-192x192.png',
+ apple: '/icons/apple-touch-icon.png',
+ },
}
export const viewport: Viewport = {
@@ -33,16 +44,18 @@ export default function RootLayout({
- {/* Ambient background effect */}
-
-
- {/* Navigation */}
-
-
- {/* Main content */}
-
- {children}
-
+
+ {/* Ambient background effect */}
+
+
+ {/* Navigation */}
+
+
+ {/* Main content */}
+
+ {children}
+
+
diff --git a/apps/web/src/app/offline/page.tsx b/apps/web/src/app/offline/page.tsx
new file mode 100644
index 00000000..e2702519
--- /dev/null
+++ b/apps/web/src/app/offline/page.tsx
@@ -0,0 +1,53 @@
+'use client'
+
+import { WifiOff, RefreshCw, Sparkles } from 'lucide-react'
+
+export default function OfflinePage() {
+ const handleRetry = () => {
+ window.location.reload()
+ }
+
+ return (
+
+
+ {/* Logo */}
+
+
+ {/* Offline Icon */}
+
+
+ {/* Message */}
+
+ Ви офлайн
+
+
+ Перевірте підключення до інтернету та спробуйте ще раз.
+ Деякі раніше відвідані сторінки можуть бути доступні з кешу.
+
+
+ {/* Retry Button */}
+
+
+ {/* Info */}
+
+ DAARION.city працює краще з інтернет-з'єднанням
+
+
+
+ )
+}
+
diff --git a/apps/web/src/components/PWAProvider.tsx b/apps/web/src/components/PWAProvider.tsx
new file mode 100644
index 00000000..351d478e
--- /dev/null
+++ b/apps/web/src/components/PWAProvider.tsx
@@ -0,0 +1,13 @@
+'use client'
+
+import { useEffect } from 'react'
+import { registerServiceWorker } from '@/lib/pwa'
+
+export function PWAProvider({ children }: { children: React.ReactNode }) {
+ useEffect(() => {
+ registerServiceWorker()
+ }, [])
+
+ return <>{children}>
+}
+
diff --git a/apps/web/src/lib/pwa.ts b/apps/web/src/lib/pwa.ts
new file mode 100644
index 00000000..1fac03f6
--- /dev/null
+++ b/apps/web/src/lib/pwa.ts
@@ -0,0 +1,83 @@
+/**
+ * PWA utilities for DAARION
+ */
+
+export function registerServiceWorker(): void {
+ if (typeof window === 'undefined') return;
+ if (!('serviceWorker' in navigator)) {
+ console.log('[PWA] Service Worker not supported');
+ return;
+ }
+
+ // Only register in production
+ if (process.env.NODE_ENV !== 'production') {
+ console.log('[PWA] Skipping SW registration in development');
+ return;
+ }
+
+ window.addEventListener('load', async () => {
+ try {
+ const registration = await navigator.serviceWorker.register('/sw.js', {
+ scope: '/'
+ });
+
+ console.log('[PWA] Service Worker registered:', registration.scope);
+
+ // Check for updates
+ registration.addEventListener('updatefound', () => {
+ const newWorker = registration.installing;
+ if (newWorker) {
+ newWorker.addEventListener('statechange', () => {
+ if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
+ // New content available
+ console.log('[PWA] New content available, refresh to update');
+ // Could show a toast notification here
+ }
+ });
+ }
+ });
+ } catch (error) {
+ console.error('[PWA] Service Worker registration failed:', error);
+ }
+ });
+}
+
+export function unregisterServiceWorker(): void {
+ if (typeof window === 'undefined') return;
+ if (!('serviceWorker' in navigator)) return;
+
+ navigator.serviceWorker.ready.then((registration) => {
+ registration.unregister().then((success) => {
+ if (success) {
+ console.log('[PWA] Service Worker unregistered');
+ }
+ });
+ });
+}
+
+/**
+ * Check if app is running in standalone mode (installed PWA)
+ */
+export function isStandalone(): boolean {
+ if (typeof window === 'undefined') return false;
+
+ return (
+ window.matchMedia('(display-mode: standalone)').matches ||
+ (window.navigator as any).standalone === true
+ );
+}
+
+/**
+ * Check if app can be installed
+ */
+export function canInstall(): boolean {
+ if (typeof window === 'undefined') return false;
+
+ // Check if already installed
+ if (isStandalone()) return false;
+
+ // Check for beforeinstallprompt support
+ return 'BeforeInstallPromptEvent' in window ||
+ 'onbeforeinstallprompt' in window;
+}
+
diff --git a/docs/web/PWA_MOBILE_SPEC.md b/docs/web/PWA_MOBILE_SPEC.md
new file mode 100644
index 00000000..e076a05b
--- /dev/null
+++ b/docs/web/PWA_MOBILE_SPEC.md
@@ -0,0 +1,243 @@
+# PWA / Mobile Specification — DAARION.city
+
+Version: 1.0.0
+
+## 0. PURPOSE
+
+Зробити `app.daarion.space` Progressive Web App (PWA):
+- **Installable** — можна додати на домашній екран як додаток
+- **Offline shell** — базові сторінки працюють без мережі
+- **Mobile-first** — оптимізовано для мобільних пристроїв
+- **Foundation for push** — підготовка до push-нотифікацій
+
+---
+
+## 1. GOALS
+
+### 1.1. Installable
+- Користувач може "Add to Home Screen" на iOS/Android/Desktop
+- Відкривається в standalone режимі (без browser UI)
+- Власна іконка та splash screen
+
+### 1.2. Offline Shell
+- Головна сторінка `/` працює з кешу
+- Список кімнат `/city` працює з кешу
+- Сторінки кімнат `/city/[slug]` — кешований shell + повідомлення "ви офлайн"
+- Matrix чат — показує offline-стан, не намагається емулювати
+
+### 1.3. Performance
+- Швидке завантаження через кешування статичних ресурсів
+- App shell architecture
+
+---
+
+## 2. MANIFEST.JSON
+
+Файл: `public/manifest.json`
+
+```json
+{
+ "name": "DAARION.city",
+ "short_name": "DAARION",
+ "description": "Децентралізована платформа для мікро-спільнот з AI-агентами",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "orientation": "portrait-primary",
+ "theme_color": "#0c4a6e",
+ "background_color": "#0f172a",
+ "icons": [
+ {
+ "src": "/icons/icon-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any maskable"
+ },
+ {
+ "src": "/icons/icon-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any maskable"
+ }
+ ],
+ "categories": ["social", "productivity"],
+ "lang": "uk"
+}
+```
+
+---
+
+## 3. ICONS
+
+Директорія: `public/icons/`
+
+| File | Size | Purpose |
+|------|------|---------|
+| `icon-192x192.png` | 192×192 | Android, Chrome |
+| `icon-512x512.png` | 512×512 | Splash screen |
+| `apple-touch-icon.png` | 180×180 | iOS |
+| `favicon.ico` | 32×32 | Browser tab |
+
+Дизайн: DAARION логотип (sparkles/зірка) на темному фоні (#0f172a)
+
+---
+
+## 4. SERVICE WORKER
+
+Файл: `public/sw.js`
+
+### 4.1. Cache Strategy
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ REQUEST FLOW │
+├─────────────────────────────────────────────────────────────┤
+│ │
+│ Static Assets (/_next/static/*, fonts, images) │
+│ └── Cache First → Return cached → Update in background │
+│ │
+│ HTML Pages (/, /city, /city/*) │
+│ └── Network First → Fallback to cache → Offline page │
+│ │
+│ API Requests (/api/*) │
+│ └── Network Only → No caching │
+│ │
+│ Matrix/WebSocket │
+│ └── Network Only → Show offline state in UI │
+│ │
+└─────────────────────────────────────────────────────────────┘
+```
+
+### 4.2. Cache Names
+
+```javascript
+const CACHE_NAME = 'daarion-v1';
+const STATIC_CACHE = 'daarion-static-v1';
+const PAGES_CACHE = 'daarion-pages-v1';
+```
+
+### 4.3. Precache List
+
+```javascript
+const PRECACHE_URLS = [
+ '/',
+ '/city',
+ '/offline',
+ '/manifest.json',
+ '/icons/icon-192x192.png',
+ '/icons/icon-512x512.png'
+];
+```
+
+---
+
+## 5. OFFLINE PAGE
+
+Файл: `src/app/offline/page.tsx`
+
+Простий екран:
+- DAARION логотип
+- "Ви офлайн"
+- "Перевірте підключення до інтернету"
+- Кнопка "Спробувати знову"
+
+---
+
+## 6. HEAD METADATA
+
+В `layout.tsx`:
+
+```tsx
+
+
+
+
+
+
+
+
+```
+
+---
+
+## 7. SW REGISTRATION
+
+В `src/lib/pwa.ts`:
+
+```typescript
+export function registerServiceWorker() {
+ if (typeof window === 'undefined') return;
+ if (!('serviceWorker' in navigator)) return;
+
+ // Only in production
+ if (process.env.NODE_ENV !== 'production') {
+ console.log('SW: Skipping registration in development');
+ return;
+ }
+
+ window.addEventListener('load', async () => {
+ try {
+ const registration = await navigator.serviceWorker.register('/sw.js');
+ console.log('SW: Registered', registration.scope);
+ } catch (error) {
+ console.error('SW: Registration failed', error);
+ }
+ });
+}
+```
+
+---
+
+## 8. TESTING CHECKLIST
+
+### 8.1. DevTools → Application
+- [ ] Manifest loads correctly
+- [ ] Icons display properly
+- [ ] Service Worker is active
+- [ ] Cache Storage has expected caches
+
+### 8.2. Install Prompt
+- [ ] Chrome shows "Install app" option
+- [ ] Safari iOS shows "Add to Home Screen"
+- [ ] App opens in standalone mode
+
+### 8.3. Offline Mode
+- [ ] `/` loads from cache
+- [ ] `/city` loads from cache
+- [ ] `/city/general` shows chat offline state
+- [ ] API calls show appropriate error
+
+### 8.4. Lighthouse
+- [ ] PWA score > 90
+- [ ] Performance > 80
+- [ ] Accessibility > 90
+
+---
+
+## 9. LIMITATIONS (MVP)
+
+- ❌ No push notifications (future phase)
+- ❌ No background sync
+- ❌ No offline message queue
+- ❌ No IndexedDB for offline data
+
+---
+
+## 10. FUTURE ENHANCEMENTS
+
+1. **Push Notifications**
+ - Web Push API
+ - VAPID keys
+ - Notification preferences
+
+2. **Background Sync**
+ - Queue messages when offline
+ - Sync when back online
+
+3. **IndexedDB**
+ - Cache chat history
+ - Offline-first architecture
+
+4. **App Badges**
+ - Unread message count on icon
+