Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add instance info panel with deployment flavor detection

Adds a "?" button in the landing page footer that opens a modal showing
what this instance can and can't do. Reads /instance-info.json (operator-
configurable) to detect three deployment flavors: public (local-only),
pds-operator (community instance), and self-hosted (full control). Each
flavor shows appropriate capabilities and limitations.

Chainlink: #15

+410
+11
public/instance-info.json
··· 1 + { 2 + "flavor": "public", 3 + "operator": null, 4 + "pds": null, 5 + "features": { 6 + "sync": false, 7 + "sharing": false, 8 + "ai": false 9 + }, 10 + "notice": null 11 + }
+97
src/css/app.css
··· 1893 1893 50% { opacity: 1; } 1894 1894 } 1895 1895 1896 + .instance-info-btn { 1897 + margin-left: auto; 1898 + background: none; 1899 + border: 1px solid var(--color-border); 1900 + border-radius: 50%; 1901 + width: 18px; 1902 + height: 18px; 1903 + font-family: var(--font-mono); 1904 + font-size: 0.6rem; 1905 + font-weight: 600; 1906 + color: var(--color-text-muted); 1907 + cursor: pointer; 1908 + display: flex; 1909 + align-items: center; 1910 + justify-content: center; 1911 + padding: 0; 1912 + line-height: 1; 1913 + flex-shrink: 0; 1914 + } 1915 + .instance-info-btn:hover { 1916 + border-color: var(--color-text-muted); 1917 + color: var(--color-text); 1918 + } 1919 + 1920 + .instance-info-panel { 1921 + max-width: 480px; 1922 + width: 90vw; 1923 + } 1924 + .instance-info-panel h3 { 1925 + font-family: var(--font-display); 1926 + font-size: 1.1rem; 1927 + font-weight: 600; 1928 + margin: 0 0 var(--space-sm); 1929 + color: var(--color-text); 1930 + } 1931 + .instance-info-panel .info-summary { 1932 + font-size: 0.85rem; 1933 + color: var(--color-text-muted); 1934 + margin: 0 0 var(--space-md); 1935 + line-height: 1.5; 1936 + } 1937 + .instance-info-panel .info-section-label { 1938 + font-family: var(--font-mono); 1939 + font-size: 0.65rem; 1940 + font-weight: 600; 1941 + text-transform: uppercase; 1942 + letter-spacing: 0.08em; 1943 + color: var(--color-text-faint); 1944 + margin: var(--space-md) 0 var(--space-xs); 1945 + } 1946 + .instance-info-panel ul { 1947 + list-style: none; 1948 + padding: 0; 1949 + margin: 0 0 var(--space-sm); 1950 + } 1951 + .instance-info-panel li { 1952 + font-size: 0.8rem; 1953 + color: var(--color-text); 1954 + padding: var(--space-xs) 0; 1955 + display: flex; 1956 + align-items: baseline; 1957 + gap: var(--space-sm); 1958 + } 1959 + .instance-info-panel li::before { 1960 + flex-shrink: 0; 1961 + font-size: 0.7rem; 1962 + } 1963 + .instance-info-panel .info-capabilities li::before { 1964 + content: '\2713'; 1965 + color: var(--color-success); 1966 + } 1967 + .instance-info-panel .info-limitations li::before { 1968 + content: '\2014'; 1969 + color: var(--color-text-faint); 1970 + } 1971 + .instance-info-panel .info-notice { 1972 + font-size: 0.8rem; 1973 + color: var(--color-text-muted); 1974 + background: var(--color-surface); 1975 + border-radius: var(--radius-sm); 1976 + padding: var(--space-sm) var(--space-md); 1977 + margin-top: var(--space-md); 1978 + line-height: 1.5; 1979 + } 1980 + .instance-info-panel .info-flavor-badge { 1981 + font-family: var(--font-mono); 1982 + font-size: 0.6rem; 1983 + font-weight: 600; 1984 + text-transform: uppercase; 1985 + letter-spacing: 0.08em; 1986 + color: var(--color-encrypted); 1987 + background: var(--color-teal-light); 1988 + padding: 0.15rem 0.45rem; 1989 + border-radius: var(--radius-sm); 1990 + margin-left: var(--space-sm); 1991 + } 1992 + 1896 1993 .desktop-download { 1897 1994 margin-top: var(--space-md); 1898 1995 text-align: center;
+1
src/index.html
··· 134 134 <div class="encryption-bar"> 135 135 <span class="encryption-dot"></span> 136 136 <span>All documents are stored locally in your browser.</span> 137 + <button class="instance-info-btn" id="instance-info-btn" title="About this instance" aria-label="About this instance">?</button> 137 138 </div> 138 139 <div class="desktop-download" id="desktop-download" style="display:none;"> 139 140 <a class="desktop-download-btn" id="desktop-download-btn" href="#" target="_blank" rel="noopener">
+9
src/landing.ts
··· 333 333 }, 334 334 }); 335 335 336 + // --- Instance info panel --- 337 + document.getElementById('instance-info-btn')?.addEventListener('click', async () => { 338 + const { getInstanceInfo, describeInstance } = await import('./lib/instance-info.js'); 339 + const info = await getInstanceInfo(); 340 + const desc = describeInstance(info); 341 + const { showInstanceInfoModal } = await import('./lib/instance-info-modal.js'); 342 + showInstanceInfoModal(desc, info); 343 + }); 344 + 336 345 // --- Init --- 337 346 initUsername(eventDeps); 338 347 ensureWrappingKey().then(() => syncKeys()).then(() => loadDocuments());
+77
src/lib/instance-info-modal.ts
··· 1 + import type { InstanceInfo } from './instance-info.js'; 2 + import { handleFocusTrap } from './modal-dialog.js'; 3 + 4 + interface FlavorDescription { 5 + title: string; 6 + summary: string; 7 + capabilities: string[]; 8 + limitations: string[]; 9 + } 10 + 11 + function escapeHtml(s: string): string { 12 + return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); 13 + } 14 + 15 + const FLAVOR_LABELS: Record<string, string> = { 16 + 'public': 'Public', 17 + 'pds-operator': 'PDS Operator', 18 + 'self-hosted': 'Self-Hosted', 19 + }; 20 + 21 + export function showInstanceInfoModal(desc: FlavorDescription, info: InstanceInfo): void { 22 + const backdrop = document.createElement('div'); 23 + backdrop.className = 'modal-dialog-backdrop'; 24 + 25 + const dialog = document.createElement('div'); 26 + dialog.className = 'modal-dialog instance-info-panel'; 27 + dialog.setAttribute('role', 'dialog'); 28 + dialog.setAttribute('aria-modal', 'true'); 29 + 30 + const flavorLabel = FLAVOR_LABELS[info.flavor] || 'Public'; 31 + let html = `<h3>${escapeHtml(desc.title)} <span class="info-flavor-badge">${escapeHtml(flavorLabel)}</span></h3>`; 32 + html += `<p class="info-summary">${escapeHtml(desc.summary)}</p>`; 33 + 34 + if (desc.capabilities.length) { 35 + html += `<div class="info-section-label">What's available</div>`; 36 + html += `<ul class="info-capabilities">${desc.capabilities.map(c => `<li>${escapeHtml(c)}</li>`).join('')}</ul>`; 37 + } 38 + 39 + if (desc.limitations.length) { 40 + html += `<div class="info-section-label">Current limitations</div>`; 41 + html += `<ul class="info-limitations">${desc.limitations.map(l => `<li>${escapeHtml(l)}</li>`).join('')}</ul>`; 42 + } 43 + 44 + if (info.notice) { 45 + html += `<div class="info-notice">${escapeHtml(info.notice)}</div>`; 46 + } 47 + 48 + html += `<div class="modal-dialog-footer"><button type="button" class="modal-dialog-ok">Close</button></div>`; 49 + 50 + dialog.innerHTML = html; 51 + backdrop.appendChild(dialog); 52 + document.body.appendChild(backdrop); 53 + 54 + const closeBtn = dialog.querySelector('.modal-dialog-ok') as HTMLButtonElement; 55 + 56 + const cleanup = () => { 57 + document.removeEventListener('keydown', onKeydown, true); 58 + backdrop.remove(); 59 + }; 60 + 61 + const onKeydown = (e: KeyboardEvent) => { 62 + if (e.key === 'Escape') { 63 + e.stopPropagation(); 64 + cleanup(); 65 + } else if (handleFocusTrap(e, dialog)) { 66 + e.stopPropagation(); 67 + } 68 + }; 69 + 70 + closeBtn.addEventListener('click', cleanup); 71 + backdrop.addEventListener('click', (e) => { 72 + if (e.target === backdrop) cleanup(); 73 + }); 74 + document.addEventListener('keydown', onKeydown, true); 75 + 76 + closeBtn.focus(); 77 + }
+127
src/lib/instance-info.ts
··· 1 + export type InstanceFlavor = 'public' | 'pds-operator' | 'self-hosted'; 2 + 3 + export interface InstanceFeatures { 4 + sync: boolean; 5 + sharing: boolean; 6 + ai: boolean; 7 + } 8 + 9 + export interface InstanceInfo { 10 + flavor: InstanceFlavor; 11 + operator: string | null; 12 + pds: string | null; 13 + features: InstanceFeatures; 14 + notice: string | null; 15 + } 16 + 17 + const DEFAULT_INFO: InstanceInfo = { 18 + flavor: 'public', 19 + operator: null, 20 + pds: null, 21 + features: { sync: false, sharing: false, ai: false }, 22 + notice: null, 23 + }; 24 + 25 + let cached: InstanceInfo | null = null; 26 + 27 + export async function getInstanceInfo(): Promise<InstanceInfo> { 28 + if (cached) return cached; 29 + 30 + try { 31 + const res = await fetch('/instance-info.json'); 32 + if (!res.ok) { 33 + cached = DEFAULT_INFO; 34 + return cached; 35 + } 36 + const data = await res.json(); 37 + cached = { 38 + flavor: validateFlavor(data.flavor), 39 + operator: typeof data.operator === 'string' ? data.operator : null, 40 + pds: typeof data.pds === 'string' ? data.pds : null, 41 + features: { 42 + sync: Boolean(data.features?.sync), 43 + sharing: Boolean(data.features?.sharing), 44 + ai: Boolean(data.features?.ai), 45 + }, 46 + notice: typeof data.notice === 'string' ? data.notice : null, 47 + }; 48 + } catch { 49 + cached = DEFAULT_INFO; 50 + } 51 + 52 + return cached; 53 + } 54 + 55 + function validateFlavor(v: unknown): InstanceFlavor { 56 + if (v === 'pds-operator' || v === 'self-hosted') return v; 57 + return 'public'; 58 + } 59 + 60 + interface FlavorDescription { 61 + title: string; 62 + summary: string; 63 + capabilities: string[]; 64 + limitations: string[]; 65 + } 66 + 67 + export function describeInstance(info: InstanceInfo): FlavorDescription { 68 + switch (info.flavor) { 69 + case 'pds-operator': { 70 + const caps: string[] = [ 71 + 'Documents, spreadsheets, slides, diagrams, forms, and calendar', 72 + 'Data stored locally in your browser', 73 + 'Sign in with your Bluesky account', 74 + ]; 75 + const limits: string[] = []; 76 + if (info.features.sync) caps.push('Sync across devices via your PDS'); 77 + else limits.push('No cross-device sync yet'); 78 + if (info.features.sharing) caps.push('Share documents with other users'); 79 + else limits.push('Sharing not yet available'); 80 + if (info.features.ai) caps.push('AI assistant available'); 81 + else limits.push('AI features not configured'); 82 + 83 + return { 84 + title: info.operator ? `Hosted by ${info.operator}` : 'PDS Operator Instance', 85 + summary: info.pds 86 + ? `This instance is connected to the PDS at ${info.pds}.` 87 + : 'This instance is run by a PDS operator for their community.', 88 + capabilities: caps, 89 + limitations: limits, 90 + }; 91 + } 92 + 93 + case 'self-hosted': 94 + return { 95 + title: 'Self-Hosted Instance', 96 + summary: 'You are running your own instance of Atmosphere Docs. You have full control over your data and configuration.', 97 + capabilities: [ 98 + 'Documents, spreadsheets, slides, diagrams, forms, and calendar', 99 + 'Data stored locally in your browser', 100 + 'Full control over configuration and data', 101 + 'Sign in with your Bluesky account', 102 + ...(info.features.ai ? ['AI assistant available'] : []), 103 + ], 104 + limitations: [ 105 + ...(!info.features.sync ? ['Cross-device sync not configured'] : []), 106 + ...(!info.features.sharing ? ['Sharing not configured'] : []), 107 + ], 108 + }; 109 + 110 + default: 111 + return { 112 + title: 'Atmosphere Docs', 113 + summary: 'A local-only office suite for the AT Protocol ecosystem. All your data stays in this browser — nothing is sent to a server.', 114 + capabilities: [ 115 + 'Documents, spreadsheets, slides, diagrams, forms, and calendar', 116 + 'All data stored locally in your browser', 117 + 'Sign in with your Bluesky account', 118 + 'Export and import documents for backup', 119 + ], 120 + limitations: [ 121 + 'Single device only — documents do not sync across devices', 122 + 'No sharing — use file export to share with others', 123 + 'AI features require separate configuration', 124 + ], 125 + }; 126 + } 127 + }
+88
tests/instance-info.test.ts
··· 1 + /** 2 + * @vitest-environment jsdom 3 + */ 4 + import { describe, it, expect } from 'vitest'; 5 + import { describeInstance, type InstanceInfo } from '../src/lib/instance-info.js'; 6 + 7 + function makeInfo(overrides: Partial<InstanceInfo> = {}): InstanceInfo { 8 + return { 9 + flavor: 'public', 10 + operator: null, 11 + pds: null, 12 + features: { sync: false, sharing: false, ai: false }, 13 + notice: null, 14 + ...overrides, 15 + }; 16 + } 17 + 18 + describe('describeInstance', () => { 19 + it('returns local-only description for public flavor', () => { 20 + const desc = describeInstance(makeInfo()); 21 + expect(desc.title).toBe('Atmosphere Docs'); 22 + expect(desc.summary).toContain('local-only'); 23 + expect(desc.capabilities.length).toBeGreaterThan(0); 24 + expect(desc.limitations).toContain('Single device only — documents do not sync across devices'); 25 + expect(desc.limitations).toContain('No sharing — use file export to share with others'); 26 + }); 27 + 28 + it('includes operator name for pds-operator flavor', () => { 29 + const desc = describeInstance(makeInfo({ 30 + flavor: 'pds-operator', 31 + operator: 'Alice', 32 + pds: 'pds.example.com', 33 + })); 34 + expect(desc.title).toBe('Hosted by Alice'); 35 + expect(desc.summary).toContain('pds.example.com'); 36 + }); 37 + 38 + it('shows sync capability when enabled for pds-operator', () => { 39 + const desc = describeInstance(makeInfo({ 40 + flavor: 'pds-operator', 41 + features: { sync: true, sharing: false, ai: false }, 42 + })); 43 + expect(desc.capabilities).toContain('Sync across devices via your PDS'); 44 + expect(desc.limitations).toContain('Sharing not yet available'); 45 + }); 46 + 47 + it('shows sharing capability when enabled for pds-operator', () => { 48 + const desc = describeInstance(makeInfo({ 49 + flavor: 'pds-operator', 50 + features: { sync: false, sharing: true, ai: false }, 51 + })); 52 + expect(desc.capabilities).toContain('Share documents with other users'); 53 + expect(desc.limitations).toContain('No cross-device sync yet'); 54 + }); 55 + 56 + it('returns self-hosted description with full control', () => { 57 + const desc = describeInstance(makeInfo({ flavor: 'self-hosted' })); 58 + expect(desc.title).toBe('Self-Hosted Instance'); 59 + expect(desc.summary).toContain('full control'); 60 + expect(desc.capabilities).toContain('Full control over configuration and data'); 61 + }); 62 + 63 + it('includes AI capability when enabled for self-hosted', () => { 64 + const desc = describeInstance(makeInfo({ 65 + flavor: 'self-hosted', 66 + features: { sync: false, sharing: false, ai: true }, 67 + })); 68 + expect(desc.capabilities).toContain('AI assistant available'); 69 + }); 70 + 71 + it('shows no sync/sharing limitations when both enabled', () => { 72 + const desc = describeInstance(makeInfo({ 73 + flavor: 'pds-operator', 74 + features: { sync: true, sharing: true, ai: true }, 75 + })); 76 + expect(desc.limitations).toHaveLength(0); 77 + }); 78 + 79 + it('falls back to public description for unknown flavor', () => { 80 + const desc = describeInstance(makeInfo({ flavor: 'unknown' as any })); 81 + expect(desc.title).toBe('Atmosphere Docs'); 82 + }); 83 + 84 + it('falls back to generic title when pds-operator has no operator name', () => { 85 + const desc = describeInstance(makeInfo({ flavor: 'pds-operator' })); 86 + expect(desc.title).toBe('PDS Operator Instance'); 87 + }); 88 + });