/** * — Tinder-style swipe UI for community activity proposals. * * Three views: Discover (swipe cards), Create (new prompt form), Profile (stats). * Multiplayer: uses CrowdSurfLocalFirstClient for real-time sync via Automerge. */ import { CrowdSurfLocalFirstClient } from '../local-first-client'; import type { CrowdSurfDoc, CrowdSurfPrompt, Contribution } from '../schemas'; import { getDecayProgress, getTimeRemaining, getRightSwipeCount, isReadyToTrigger, getUrgency, parseContributions } from '../schemas'; // ── Auth helpers ── function getSession(): { accessToken: string; claims: { sub: string; did?: string; username?: string } } | null { try { const raw = localStorage.getItem('encryptid_session'); if (!raw) return null; const s = JSON.parse(raw); return s?.accessToken ? s : null; } catch { return null; } } function getMyDid(): string | null { const s = getSession(); if (!s) return null; return (s.claims as any).did || s.claims.sub; } type ViewTab = 'discover' | 'create' | 'profile'; class FolkCrowdSurfDashboard extends HTMLElement { private shadow: ShadowRoot; private space: string; // State private activeTab: ViewTab = 'discover'; private loading = true; private prompts: CrowdSurfPrompt[] = []; private currentPromptIndex = 0; // Swipe state private isDragging = false; private startX = 0; private currentX = 0; private isAnimating = false; // Create form state private contributionSuggestions: string[] = []; // Profile stats private stats = { joined: 0, created: 0, triggered: 0 }; // Multiplayer private lfClient: CrowdSurfLocalFirstClient | null = null; private _lfcUnsub: (() => void) | null = null; // Expiry timer private _expiryTimer: number | null = null; constructor() { super(); this.shadow = this.attachShadow({ mode: 'open' }); this.space = this.getAttribute('space') || 'demo'; } connectedCallback() { if (this.space === 'demo') { this.loadDemoData(); } else { this.initMultiplayer(); } } disconnectedCallback() { this._lfcUnsub?.(); this._lfcUnsub = null; this.lfClient?.disconnect(); if (this._expiryTimer !== null) clearInterval(this._expiryTimer); } // ── Multiplayer init ── private async initMultiplayer() { this.loading = true; this.render(); try { this.lfClient = new CrowdSurfLocalFirstClient(this.space); await this.lfClient.init(); await this.lfClient.subscribe(); this._lfcUnsub = this.lfClient.onChange((doc) => { this.extractPrompts(doc); this.render(); this.bindEvents(); }); const doc = this.lfClient.getDoc(); if (doc) this.extractPrompts(doc); } catch (err) { console.warn('[CrowdSurf] Local-first init failed:', err); } this.loading = false; this.render(); this.bindEvents(); // Check expiry every 30s this._expiryTimer = window.setInterval(() => this.checkExpiry(), 30000); } private extractPrompts(doc: CrowdSurfDoc) { const myDid = getMyDid(); const all = doc.prompts ? Object.values(doc.prompts) : []; // Sort: active first (by creation, newest first), then triggered, then expired this.prompts = all.sort((a, b) => { if (a.expired !== b.expired) return a.expired ? 1 : -1; if (a.triggered !== b.triggered) return a.triggered ? 1 : -1; return b.createdAt - a.createdAt; }); // Compute profile stats if (myDid) { this.stats.created = all.filter(p => p.createdBy === myDid).length; this.stats.joined = all.filter(p => p.swipes[myDid]?.direction === 'right').length; this.stats.triggered = all.filter(p => p.triggered).length; } } private checkExpiry() { if (!this.lfClient) return; const doc = this.lfClient.getDoc(); if (!doc?.prompts) return; for (const prompt of Object.values(doc.prompts)) { if (!prompt.expired && getDecayProgress(prompt) >= 1) { this.lfClient.markExpired(prompt.id); } } } // ── Demo data ── private loadDemoData() { const now = Date.now(); this.prompts = [ { id: 'demo-1', text: 'Community Garden Planting Day', location: 'Tempelhof Field, Berlin', threshold: 5, duration: 4, activityDuration: '3 hours', createdAt: now - 3600000, createdBy: null, triggered: false, expired: false, swipes: { 'alice': { direction: 'right', timestamp: now - 2000000, contribution: { bringing: ['seedlings', 'gardening gloves'], needed: ['watering cans'], tags: ['food'], value: 10 } }, 'bob': { direction: 'right', timestamp: now - 1800000 }, 'carol': { direction: 'right', timestamp: now - 1500000 } }, }, { id: 'demo-2', text: 'Open Mic & Jam Session', location: 'Klunkerkranich rooftop', threshold: 8, duration: 6, activityDuration: '2 hours', createdAt: now - 7200000, createdBy: null, triggered: false, expired: false, swipes: { 'dave': { direction: 'right', timestamp: now - 5000000, contribution: { bringing: ['guitar', 'amp'], needed: ['microphone'], tags: ['music'], value: 15 } }, 'eve': { direction: 'right', timestamp: now - 4000000 }, 'frank': { direction: 'right', timestamp: now - 3500000 }, 'grace': { direction: 'right', timestamp: now - 3000000 }, 'hank': { direction: 'right', timestamp: now - 2500000 }, 'iris': { direction: 'right', timestamp: now - 2000000 } }, }, { id: 'demo-3', text: 'Repair Cafe — Fix Your Stuff!', location: 'Maker Space, Kreuzberg', threshold: 3, duration: 2, activityDuration: '4 hours', createdAt: now - 600000, createdBy: null, triggered: false, expired: false, swipes: { 'jack': { direction: 'right', timestamp: now - 400000, contribution: { bringing: ['soldering iron', 'electronics skills'], needed: ['broken gadgets'], tags: ['tech'], value: 15 } } }, }, { id: 'demo-4', text: 'Sunrise Yoga by the Canal', location: 'Landwehr Canal', threshold: 4, duration: 8, activityDuration: '1 hour', createdAt: now - 1000000, createdBy: null, triggered: false, expired: false, swipes: {}, }, ]; this.loading = false; this.render(); this.bindEvents(); } // ── Swipe mechanics ── private getActivePrompts(): CrowdSurfPrompt[] { const myDid = getMyDid(); return this.prompts.filter(p => !p.expired && !p.triggered && (this.space === 'demo' || !myDid || !p.swipes[myDid]) ); } private getCurrentPrompt(): CrowdSurfPrompt | null { const active = this.getActivePrompts(); if (this.currentPromptIndex >= active.length) this.currentPromptIndex = 0; return active[this.currentPromptIndex] ?? null; } private handleSwipeEnd(diffX: number) { if (Math.abs(diffX) < 100) return; // Below threshold const prompt = this.getCurrentPrompt(); if (!prompt) return; if (diffX > 0) { this.performSwipe(prompt, 'right'); } else { this.performSwipe(prompt, 'left'); } } private performSwipe(prompt: CrowdSurfPrompt, direction: 'right' | 'left') { const myDid = getMyDid(); if (this.space === 'demo') { // Demo mode: just advance if (direction === 'right') { prompt.swipes['demo-user'] = { direction: 'right', timestamp: Date.now() }; this.stats.joined++; if (getRightSwipeCount(prompt) >= prompt.threshold) { prompt.triggered = true; this.stats.triggered++; } } this.currentPromptIndex++; this.isAnimating = true; setTimeout(() => { this.isAnimating = false; this.render(); this.bindEvents(); }, 300); return; } if (!myDid || !this.lfClient) return; // TODO: show contribution modal on right-swipe before committing this.lfClient.swipe(prompt.id, myDid, direction); this.currentPromptIndex++; this.isAnimating = true; setTimeout(() => { this.isAnimating = false; this.render(); this.bindEvents(); }, 300); } // ── Create prompt ── private handleCreate() { const getText = (id: string) => (this.shadow.getElementById(id) as HTMLInputElement | HTMLTextAreaElement)?.value?.trim() ?? ''; const getNum = (id: string) => parseInt((this.shadow.getElementById(id) as HTMLInputElement)?.value ?? '0', 10); const text = getText('cs-text'); const location = getText('cs-location'); const threshold = getNum('cs-threshold') || 3; const duration = getNum('cs-duration') || 4; const activityDuration = getText('cs-activity-duration') || '1 hour'; const bringing = getText('cs-bringing'); const needed = getText('cs-needed'); if (!text || !location) return; const prompt: CrowdSurfPrompt = { id: crypto.randomUUID(), text, location, threshold, duration, activityDuration, createdAt: Date.now(), createdBy: getMyDid(), triggered: false, expired: false, swipes: {}, }; // Add creator's contribution as a right-swipe const myDid = getMyDid(); if (myDid && (bringing || needed)) { const contribution = parseContributions(bringing, needed); prompt.swipes[myDid] = { direction: 'right', timestamp: Date.now(), contribution, }; } if (this.space === 'demo') { this.prompts.unshift(prompt); this.stats.created++; } else if (this.lfClient) { this.lfClient.createPrompt(prompt); } this.activeTab = 'discover'; this.render(); this.bindEvents(); } // ── Render ── private render() { const isLive = this.lfClient?.isConnected ?? false; this.shadow.innerHTML = `
${this.loading ? '
Loading...
' : this.renderActiveView(isLive)}
`; } private renderActiveView(isLive: boolean): string { switch (this.activeTab) { case 'discover': return this.renderDiscover(isLive); case 'create': return this.renderCreateForm(); case 'profile': return this.renderProfile(); } } // ── Discover view (swipe cards) ── private renderDiscover(isLive: boolean): string { const prompt = this.getCurrentPrompt(); const activeCount = this.getActivePrompts().length; const triggeredPrompts = this.prompts.filter(p => p.triggered); return `
CrowdSurf ${isLive ? 'LIVE' : ''} ${this.space === 'demo' ? 'DEMO' : ''}
${prompt ? this.renderCard(prompt, activeCount) : this.renderNoCards()} ${triggeredPrompts.length > 0 ? `
${triggeredPrompts.map(p => `
🚀
${this.esc(p.text)}
📍 ${this.esc(p.location)} · ${getRightSwipeCount(p)} people
`).join('')}
` : ''}
`; } private renderCard(prompt: CrowdSurfPrompt, totalActive: number): string { const rightSwipes = getRightSwipeCount(prompt); const progressPct = Math.min((rightSwipes / prompt.threshold) * 100, 100); const urgency = getUrgency(prompt); const timeLeft = getTimeRemaining(prompt); // Collect contributions const allContribs = Object.values(prompt.swipes) .filter(s => s.direction === 'right' && s.contribution) .map(s => s.contribution!); const bringingAll = allContribs.flatMap(c => c.bringing); const neededAll = allContribs.flatMap(c => c.needed); return `
✗ Pass
✓ Join
${this.esc(prompt.text)}
📍 ${this.esc(prompt.location)}
${prompt.activityDuration ? `
⏱️ ${this.esc(prompt.activityDuration)}
` : ''} ${bringingAll.length > 0 ? `
🎒 People are bringing:
${bringingAll.map(b => `${this.esc(b)}`).join('')}
` : ''} ${neededAll.length > 0 ? `
✨ Still needed:
${neededAll.map(n => `${this.esc(n)}`).join('')}
` : ''} ${isReadyToTrigger(prompt) ? '
🚀 Group ready! Activity happening!
' : ''}
${this.currentPromptIndex + 1} / ${totalActive}
`; } private renderNoCards(): string { return `
🏄

No activities to discover right now.

Create one and get the wave started!

`; } // ── Create form ── private renderCreateForm(): string { return `
New Activity
`; } // ── Profile view ── private renderProfile(): string { const allTriggered = this.prompts.filter(p => p.triggered); const totalParticipants = this.prompts.reduce((sum, p) => sum + getRightSwipeCount(p), 0); return `
Profile
${this.stats.joined}
Joined
${this.stats.created}
Created
${this.stats.triggered}
Triggered
Active prompts${this.prompts.filter(p => !p.expired && !p.triggered).length}
Total triggered${allTriggered.length}
Total participants${totalParticipants}
`; } // ── Event binding ── private bindEvents() { // Tab navigation this.shadow.querySelectorAll('.cs-nav-btn').forEach(btn => { btn.addEventListener('click', () => { const tab = btn.dataset.tab as ViewTab; if (tab && tab !== this.activeTab) { this.activeTab = tab; this.render(); this.bindEvents(); } }); }); // Swipe gesture on card const card = this.shadow.getElementById('cs-current-card'); if (card) this.setupSwipeGestures(card); // Swipe buttons this.shadow.querySelector('[data-action="swipe-left"]')?.addEventListener('click', () => { const prompt = this.getCurrentPrompt(); if (prompt) this.performSwipe(prompt, 'left'); }); this.shadow.querySelector('[data-action="swipe-right"]')?.addEventListener('click', () => { const prompt = this.getCurrentPrompt(); if (prompt) this.performSwipe(prompt, 'right'); }); // Go to create tab this.shadow.querySelector('[data-action="go-create"]')?.addEventListener('click', () => { this.activeTab = 'create'; this.render(); this.bindEvents(); }); // Create form submit this.shadow.querySelector('[data-action="create-prompt"]')?.addEventListener('click', () => this.handleCreate()); } private setupSwipeGestures(card: HTMLElement) { const handleStart = (clientX: number) => { if (this.isAnimating) return; this.startX = clientX; this.currentX = clientX; this.isDragging = true; }; const handleMove = (clientX: number) => { if (!this.isDragging || this.isAnimating) return; this.currentX = clientX; const diffX = this.currentX - this.startX; const rotation = diffX * 0.1; card.style.transform = `translateX(${diffX}px) rotate(${rotation}deg)`; const leftInd = card.querySelector('.cs-swipe-left') as HTMLElement; const rightInd = card.querySelector('.cs-swipe-right') as HTMLElement; if (diffX < -50) { leftInd?.classList.add('show'); rightInd?.classList.remove('show'); } else if (diffX > 50) { rightInd?.classList.add('show'); leftInd?.classList.remove('show'); } else { leftInd?.classList.remove('show'); rightInd?.classList.remove('show'); } }; const handleEnd = () => { if (!this.isDragging || this.isAnimating) return; this.isDragging = false; const diffX = this.currentX - this.startX; card.querySelector('.cs-swipe-left')?.classList.remove('show'); card.querySelector('.cs-swipe-right')?.classList.remove('show'); if (Math.abs(diffX) > 100) { // Animate out const direction = diffX > 0 ? 1 : -1; card.style.transition = 'transform 0.3s ease-out, opacity 0.3s ease-out'; card.style.transform = `translateX(${direction * 500}px) rotate(${direction * 30}deg)`; card.style.opacity = '0'; this.handleSwipeEnd(diffX); } else { card.style.transition = 'transform 0.2s ease-out'; card.style.transform = ''; setTimeout(() => { card.style.transition = ''; }, 200); } }; // Pointer events (unified touch + mouse) card.addEventListener('pointerdown', (e: PointerEvent) => { if (e.button !== 0) return; card.setPointerCapture(e.pointerId); card.style.touchAction = 'none'; handleStart(e.clientX); }); card.addEventListener('pointermove', (e: PointerEvent) => { e.preventDefault(); handleMove(e.clientX); }); card.addEventListener('pointerup', () => handleEnd()); card.addEventListener('pointercancel', () => { this.isDragging = false; card.style.transform = ''; card.style.touchAction = ''; }); } // ── Styles ── private getStyles(): string { return ` :host { display: block; height: 100%; -webkit-tap-highlight-color: transparent; } button, a, input, select, textarea, [role="button"] { touch-action: manipulation; } * { box-sizing: border-box; } .cs-app { display: flex; flex-direction: column; height: 100%; min-height: calc(100vh - 56px); background: var(--rs-bg-page); color: var(--rs-text-primary); } .cs-loading { text-align: center; padding: 4rem; color: var(--rs-text-muted); flex: 1; display: flex; align-items: center; justify-content: center; } /* Header */ .cs-header { display: flex; align-items: center; gap: 8px; padding: 1rem 1.25rem 0.5rem; } .cs-title { font-size: 1.1rem; font-weight: 700; flex: 1; } .cs-live { font-size: 0.7rem; padding: 2px 8px; border-radius: 999px; background: rgba(34,197,94,0.15); color: #22c55e; font-weight: 500; display: flex; align-items: center; gap: 3px; } .cs-live-dot { width: 6px; height: 6px; border-radius: 50%; background: #22c55e; animation: cs-pulse 2s infinite; } @keyframes cs-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } } .cs-demo-badge { font-size: 0.7rem; padding: 2px 8px; border-radius: 999px; background: var(--rs-primary); color: #fff; font-weight: 500; } /* Discover */ .cs-discover { flex: 1; display: flex; flex-direction: column; padding: 0.5rem 1.25rem 1rem; overflow-y: auto; } /* Card stack */ .cs-card-stack { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 320px; position: relative; } .cs-card { width: 100%; max-width: 380px; background: linear-gradient(135deg, rgba(102,126,234,0.15), rgba(118,75,162,0.15)); border: 1px solid var(--rs-border); border-radius: 16px; padding: 1.5rem; position: relative; cursor: grab; user-select: none; transition: box-shadow 0.2s; } .cs-card:active { cursor: grabbing; } .cs-card:hover { box-shadow: 0 8px 32px rgba(0,0,0,0.2); } .cs-card-count { font-size: 0.75rem; color: var(--rs-text-muted); margin-top: 0.75rem; text-align: center; } /* Swipe indicators */ .cs-swipe-indicator { position: absolute; top: 1.5rem; font-size: 1.25rem; font-weight: 800; padding: 6px 16px; border-radius: 8px; opacity: 0; transition: opacity 0.15s; pointer-events: none; z-index: 2; } .cs-swipe-left { left: 1rem; color: #ef4444; border: 2px solid #ef4444; } .cs-swipe-right { right: 1rem; color: #22c55e; border: 2px solid #22c55e; } .cs-swipe-indicator.show { opacity: 1; } /* Card body */ .cs-card-body { position: relative; z-index: 1; } .cs-card-text { font-size: 1.2rem; font-weight: 700; margin-bottom: 1rem; line-height: 1.4; } .cs-card-location { font-size: 0.9rem; margin-bottom: 0.5rem; opacity: 0.85; } .cs-card-duration { font-size: 0.85rem; margin-bottom: 1rem; opacity: 0.7; background: rgba(255,255,255,0.08); display: inline-block; padding: 4px 10px; border-radius: 8px; } /* Contributions */ .cs-contrib-section { margin-bottom: 0.75rem; } .cs-contrib-label { font-size: 0.78rem; color: var(--rs-text-secondary); margin-bottom: 4px; } .cs-contrib-tags { display: flex; flex-wrap: wrap; gap: 4px; } .cs-tag { font-size: 0.75rem; padding: 3px 8px; border-radius: 999px; background: rgba(94,234,212,0.12); color: #5eead4; } .cs-tag.needed { background: rgba(251,191,36,0.12); color: #fbbf24; } /* Card footer */ .cs-card-footer { margin-top: 0.75rem; } .cs-pool { font-size: 0.85rem; font-weight: 600; margin-bottom: 6px; text-align: center; } .cs-progress-bar { height: 6px; border-radius: 999px; background: rgba(255,255,255,0.1); overflow: hidden; position: relative; } .cs-progress-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, #667eea, #764ba2); transition: width 0.5s ease-out; } .cs-urgency-pulse { position: absolute; inset: 0; border-radius: 999px; background: rgba(239,68,68,0.3); animation: cs-urgency 1s infinite; } @keyframes cs-urgency { 0%,100% { opacity: 0; } 50% { opacity: 1; } } .cs-time { font-size: 0.78rem; text-align: center; margin-top: 6px; color: var(--rs-text-secondary); } .cs-time.urgent { color: #ef4444; animation: cs-blink 1s infinite; } @keyframes cs-blink { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } } .cs-trigger-msg { text-align: center; margin-top: 0.75rem; padding: 8px; border-radius: 8px; background: rgba(34,197,94,0.15); color: #22c55e; font-weight: 600; font-size: 0.9rem; } /* Swipe buttons */ .cs-swipe-buttons { display: flex; justify-content: center; gap: 2rem; padding: 1rem 0; } .cs-btn-skip, .cs-btn-join { width: 56px; height: 56px; border-radius: 50%; border: 2px solid; font-size: 1.5rem; cursor: pointer; display: flex; align-items: center; justify-content: center; background: var(--rs-bg-surface); transition: all 0.15s; font-family: inherit; } .cs-btn-skip { border-color: #ef4444; color: #ef4444; } .cs-btn-skip:hover { background: rgba(239,68,68,0.15); } .cs-btn-join { border-color: #22c55e; color: #22c55e; } .cs-btn-join:hover { background: rgba(34,197,94,0.15); } /* Triggered list */ .cs-section-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--rs-text-muted); margin: 1.25rem 0 0.5rem; font-weight: 600; } .cs-triggered-list { display: flex; flex-direction: column; gap: 6px; } .cs-triggered-card { display: flex; align-items: center; gap: 10px; padding: 0.75rem; background: var(--rs-bg-surface); border: 1px solid rgba(34,197,94,0.2); border-radius: 10px; } .cs-triggered-icon { font-size: 1.25rem; } .cs-triggered-info { flex: 1; min-width: 0; } .cs-triggered-title { font-weight: 600; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .cs-triggered-meta { font-size: 0.78rem; color: var(--rs-text-secondary); } /* Empty state */ .cs-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; color: var(--rs-text-muted); padding: 2rem; } .cs-empty-icon { font-size: 3rem; margin-bottom: 1rem; } .cs-empty p { margin: 0.25rem 0; font-size: 0.9rem; } .cs-btn-create { margin-top: 1rem; padding: 0.6rem 1.5rem; border-radius: 10px; border: 1px solid var(--rs-primary); background: var(--rs-primary); color: #fff; font-size: 0.9rem; cursor: pointer; font-family: inherit; } /* Create form */ .cs-form { flex: 1; padding: 0.5rem 1.25rem 2rem; overflow-y: auto; } .cs-label { display: block; font-size: 0.8rem; font-weight: 600; color: var(--rs-text-secondary); margin: 1rem 0 0.35rem; } .cs-label:first-child { margin-top: 0; } .cs-input { width: 100%; padding: 0.6rem 0.75rem; border-radius: 8px; border: 1px solid var(--rs-border); background: var(--rs-bg-surface); color: var(--rs-text-primary); font-size: 0.9rem; font-family: inherit; } .cs-input:focus { outline: none; border-color: var(--rs-primary); } .cs-textarea { resize: vertical; } .cs-form-row { display: flex; gap: 0.75rem; } .cs-form-col { flex: 1; } .cs-divider { border: none; border-top: 1px solid var(--rs-border); margin: 1.25rem 0; height: 0; } .cs-btn-submit { width: 100%; padding: 0.75rem; border-radius: 10px; border: none; background: linear-gradient(135deg, #667eea, #764ba2); color: #fff; font-size: 1rem; font-weight: 600; cursor: pointer; margin-top: 1.5rem; font-family: inherit; } .cs-btn-submit:hover { opacity: 0.9; } /* Profile */ .cs-profile { flex: 1; padding: 0.5rem 1.25rem 2rem; overflow-y: auto; } .cs-stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; margin-bottom: 1.5rem; } .cs-stat-card { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 12px; padding: 1rem; text-align: center; } .cs-stat-num { font-size: 1.5rem; font-weight: 800; background: linear-gradient(135deg, #667eea, #764ba2); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } .cs-stat-label { font-size: 0.75rem; color: var(--rs-text-secondary); margin-top: 2px; } .cs-community-stats { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 10px; overflow: hidden; } .cs-community-row { display: flex; justify-content: space-between; padding: 0.6rem 1rem; border-bottom: 1px solid var(--rs-border); font-size: 0.85rem; } .cs-community-row:last-child { border-bottom: none; } .cs-community-row span:last-child { font-weight: 600; } /* Bottom nav */ .cs-nav { display: flex; border-top: 1px solid var(--rs-border); background: var(--rs-bg-surface); padding: 0.35rem 0 env(safe-area-inset-bottom, 0.35rem); flex-shrink: 0; } .cs-nav-btn { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 0.4rem 0; border: none; background: none; color: var(--rs-text-muted); cursor: pointer; font-family: inherit; transition: color 0.15s; } .cs-nav-btn:hover, .cs-nav-btn.active { color: var(--rs-text-primary); } .cs-nav-btn.active .cs-nav-icon { transform: scale(1.15); } .cs-nav-icon { font-size: 1.25rem; transition: transform 0.15s; } .cs-nav-label { font-size: 0.65rem; font-weight: 500; } @media (max-width: 480px) { .cs-card { padding: 1.25rem; } .cs-card-text { font-size: 1.05rem; } .cs-form { padding: 0.5rem 1rem 2rem; } } `; } private esc(s: string): string { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } } customElements.define('folk-crowdsurf-dashboard', FolkCrowdSurfDashboard);