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 setup flow, non-dismissible login modal, operator preview, and improved user badge

- Setup screen shows variant-specific capabilities, limitations, and liability
disclaimer before sign-in (powered by describeInstance)
- Login modal no longer dismissible via backdrop click
- Operator preview mode via ?preview-flavor=pds-operator URL parameter
- User badge restyled as pill with border, flex layout, sign-out tooltip
- Skip button hidden by default (only relevant in open access mode)

Closes #25, closes #26

+390 -20
+6
CHANGELOG.md
··· 9 9 ### Added 10 10 11 11 ### Fixed 12 + - Fix suggestion dropdown position: render above input inside modal (#11) 12 13 - Remove misleading Connecting status from editors (#14) 13 14 14 15 ### Changed 16 + - Rename atmosphere-docs to atmosphere-office in homelab-nix (#23) 17 + - Rename atmosphere-docs to atmosphere-office in Nomad (#22) 18 + - Investigate homelab-nix post-merge deploy failure (atmosphere-office service is healthy) (#24) 19 + - Add allowlist auth gate with Bluesky follow CTA (#20) 20 + - Fix public repo descriptions on atcr.io and Tangled (#18) 15 21 - Public isolation: atcr.io registry, Tangled publishing, Docker Compose, operator docs (#17) 16 22 - Hide unsupported features based on instance-info, rename to Atmosphere Office (#16) 17 23 - Add instance info panel with deployment flavor detection (#15)
+65 -5
src/css/app.css
··· 61 61 --radius-sm: 3px; 62 62 --radius-md: 6px; 63 63 --radius-lg: 10px; 64 + --radius-pill: 9999px; 64 65 65 66 --shadow-sm: 0 1px 2px #2218120f; 66 67 --shadow-sm: 0 1px 2px oklch(0.22 0.02 55 / 0.06); ··· 1797 1798 gap: var(--space-sm); 1798 1799 } 1799 1800 1801 + /* Setup screen */ 1802 + .setup-modal { 1803 + max-width: 480px; 1804 + } 1805 + .setup-modal h3 { 1806 + font-size: 0.8rem; 1807 + font-weight: 600; 1808 + text-transform: uppercase; 1809 + letter-spacing: 0.04em; 1810 + color: var(--color-text-muted); 1811 + margin-bottom: var(--space-xs); 1812 + } 1813 + .setup-capabilities, 1814 + .setup-limitations { 1815 + list-style: none; 1816 + padding: 0; 1817 + margin: 0 0 var(--space-md); 1818 + font-size: 0.85rem; 1819 + color: var(--color-text-muted); 1820 + line-height: 1.6; 1821 + } 1822 + .setup-capabilities li::before { 1823 + content: "\2713\00a0"; 1824 + color: var(--color-accent); 1825 + font-weight: 600; 1826 + } 1827 + .setup-limitations li::before { 1828 + content: "\2022\00a0"; 1829 + color: var(--color-text-faint); 1830 + } 1831 + .setup-disclaimer { 1832 + border-top: 1px solid var(--color-border); 1833 + padding-top: var(--space-sm); 1834 + margin-bottom: var(--space-md); 1835 + } 1836 + .setup-disclaimer p { 1837 + font-size: 0.78rem; 1838 + color: var(--color-text-faint); 1839 + line-height: 1.5; 1840 + margin: 0; 1841 + } 1842 + .setup-modal .btn-primary { 1843 + width: 100%; 1844 + } 1845 + .setup-preview-banner { 1846 + background: var(--color-accent); 1847 + color: var(--color-bg); 1848 + font-size: 0.75rem; 1849 + font-weight: 600; 1850 + text-align: center; 1851 + padding: var(--space-xs) var(--space-sm); 1852 + border-radius: var(--radius-sm); 1853 + margin-bottom: var(--space-md); 1854 + } 1855 + 1800 1856 /* Waitlist modal */ 1801 1857 .waitlist-modal { 1802 1858 max-width: 460px; ··· 1891 1947 /* User badge in topbar */ 1892 1948 .user-badge { 1893 1949 display: none; 1950 + align-items: center; 1951 + gap: var(--space-xs); 1894 1952 padding: var(--space-xs) var(--space-sm); 1895 1953 background: var(--color-surface-alt); 1896 - border-radius: var(--radius-sm); 1954 + border: 1px solid var(--color-border); 1955 + border-radius: var(--radius-pill); 1897 1956 font-size: 0.8rem; 1898 1957 color: var(--color-text-muted); 1899 1958 cursor: pointer; 1900 1959 transition: all var(--transition-fast); 1960 + white-space: nowrap; 1901 1961 } 1902 1962 .user-badge:hover { 1903 1963 background: var(--color-hover); 1904 1964 color: var(--color-text); 1965 + border-color: var(--color-accent); 1905 1966 } 1906 1967 .user-badge .user-avatar { 1907 - width: 20px; 1908 - height: 20px; 1968 + width: 22px; 1969 + height: 22px; 1909 1970 border-radius: 50%; 1910 - margin-right: var(--space-xs); 1911 - vertical-align: middle; 1971 + flex-shrink: 0; 1912 1972 } 1913 1973 1914 1974 /* Doc owner in list */
+23 -8
src/index.html
··· 152 152 </footer> 153 153 </main> 154 154 155 + <!-- Setup screen (shown before sign-in for new visitors) --> 156 + <div class="modal-backdrop" id="setup-screen" style="display:none;"> 157 + <div class="modal setup-modal" role="dialog" aria-modal="true" aria-labelledby="setup-title"> 158 + <div class="setup-preview-banner" style="display:none;"></div> 159 + <h2 class="setup-title" id="setup-title"></h2> 160 + <p class="setup-summary"></p> 161 + <div class="setup-capabilities-section"> 162 + <h3>What you can do</h3> 163 + <ul class="setup-capabilities"></ul> 164 + </div> 165 + <div class="setup-limitations-section"> 166 + <h3>Current limitations</h3> 167 + <ul class="setup-limitations"></ul> 168 + </div> 169 + <div class="setup-disclaimer"> 170 + <p>All data is stored locally in your browser. Atmosphere Mail LLC does not store, access, or back up your documents. You are solely responsible for your data.</p> 171 + </div> 172 + <button id="setup-continue" class="btn-primary">Continue to Sign In</button> 173 + </div> 174 + </div> 175 + 155 176 <!-- Sign-in modal --> 156 177 <div class="modal-backdrop" id="username-modal" style="display:none;"> 157 178 <div class="modal username-modal" role="dialog" aria-modal="true" aria-labelledby="username-modal-title"> 158 - <h2 id="username-modal-title">Atmosphere Office</h2> 159 - <p class="welcome-tagline">A local-only office suite for the AT Protocol ecosystem.</p> 160 - <ul class="welcome-features"> 161 - <li>Documents, spreadsheets, slides, diagrams, forms, and calendar</li> 162 - <li>All data stored locally in your browser &mdash; nothing is sent to a server</li> 163 - <li>Sign in with your Bluesky account</li> 164 - </ul> 179 + <h2 id="username-modal-title">Sign in</h2> 165 180 <p class="welcome-signin-label">Sign in with your Bluesky account</p> 166 181 <input type="text" class="username-input" id="username-input" placeholder="you.bsky.social" maxlength="100" autofocus /> 167 182 <div class="username-modal-actions"> 168 - <button class="btn-secondary" id="username-skip">Skip</button> 183 + <button class="btn-secondary" id="username-skip" style="display:none;">Skip</button> 169 184 <button class="btn-primary" id="username-confirm">Sign In</button> 170 185 </div> 171 186 </div>
+16 -5
src/landing-events-identity.ts
··· 7 7 import { initAuth, signIn, getSession, onSessionChange } from './lib/auth.js'; 8 8 import { debouncedSearch, type HandleSuggestion } from './lib/handle-search.js'; 9 9 import { checkAccess, applyAccessGate, showWaitlistModal } from './lib/access-gate.js'; 10 + import { renderSetupScreen, initSetupFlow, hasSeenSetup } from './lib/setup-flow.js'; 10 11 11 12 function showUserBadge(deps: EventDeps, name: string, avatar?: string | null): void { 12 13 if (avatar) { 13 - deps.userBadge.innerHTML = `<img src="${avatar}" alt="" class="user-avatar" />${name}`; 14 + deps.userBadge.innerHTML = `<img src="${escapeHtml(avatar)}" alt="" class="user-avatar" />${escapeHtml(name)}`; 14 15 } else { 15 16 deps.userBadge.textContent = name; 16 17 } 17 - deps.userBadge.title = name; 18 - deps.userBadge.style.display = ''; 18 + deps.userBadge.title = `Signed in as ${name} — click to sign out`; 19 + deps.userBadge.style.display = 'flex'; 19 20 } 20 21 21 22 function escapeHtml(s: string): string { ··· 155 156 return; 156 157 } 157 158 158 - deps.usernameModal.style.display = ''; 159 - deps.usernameInput.focus(); 159 + const showLoginModal = () => { 160 + deps.usernameModal.style.display = ''; 161 + deps.usernameInput.focus(); 162 + }; 163 + 164 + if (hasSeenSetup()) { 165 + showLoginModal(); 166 + return; 167 + } 168 + 169 + initSetupFlow(showLoginModal); 170 + await renderSetupScreen(); 160 171 } 161 172 162 173 export function attachUsernameListeners(deps: EventDeps): void {
+2 -2
src/landing-events.ts
··· 255 255 // --- Username --- 256 256 attachUsernameListeners(deps); 257 257 258 - // --- Close modals on backdrop click --- 259 - [deps.usernameModal, deps.folderModal, deps.moveModal].forEach(modal => { 258 + // --- Close modals on backdrop click (username modal excluded — non-dismissible) --- 259 + [deps.folderModal, deps.moveModal].forEach(modal => { 260 260 modal.addEventListener('click', (e) => { 261 261 if (e.target === modal) { 262 262 modal.style.display = 'none';
+79
src/lib/setup-flow.ts
··· 1 + import { getInstanceInfo, describeInstance, type InstanceFlavor } from './instance-info.js'; 2 + 3 + const SETUP_SEEN_KEY = 'atmos-setup-seen'; 4 + 5 + export function getPreviewFlavor(search: string = window.location.search): InstanceFlavor | null { 6 + const params = new URLSearchParams(search); 7 + const preview = params.get('preview-flavor'); 8 + if (preview === 'pds-operator' || preview === 'self-hosted' || preview === 'public') { 9 + return preview; 10 + } 11 + return null; 12 + } 13 + 14 + export function hasSeenSetup(): boolean { 15 + return sessionStorage.getItem(SETUP_SEEN_KEY) === '1'; 16 + } 17 + 18 + function markSetupSeen(): void { 19 + sessionStorage.setItem(SETUP_SEEN_KEY, '1'); 20 + } 21 + 22 + export async function renderSetupScreen(previewOverride?: InstanceFlavor): Promise<void> { 23 + const info = await getInstanceInfo(); 24 + const previewFlavor = previewOverride ?? getPreviewFlavor(); 25 + 26 + const effectiveInfo = previewFlavor ? { ...info, flavor: previewFlavor } : info; 27 + const desc = describeInstance(effectiveInfo); 28 + 29 + const screen = document.getElementById('setup-screen'); 30 + if (!screen) return; 31 + 32 + const title = screen.querySelector('.setup-title'); 33 + if (title) title.textContent = desc.title; 34 + 35 + const summary = screen.querySelector('.setup-summary'); 36 + if (summary) summary.textContent = desc.summary; 37 + 38 + const capsList = screen.querySelector('.setup-capabilities'); 39 + if (capsList) { 40 + capsList.innerHTML = desc.capabilities.map(c => `<li>${escapeHtml(c)}</li>`).join(''); 41 + } 42 + 43 + const limitsSection = screen.querySelector('.setup-limitations-section') as HTMLElement | null; 44 + const limitsList = screen.querySelector('.setup-limitations'); 45 + if (limitsList && desc.limitations.length > 0) { 46 + limitsList.innerHTML = desc.limitations.map(l => `<li>${escapeHtml(l)}</li>`).join(''); 47 + if (limitsSection) limitsSection.style.display = ''; 48 + } else if (limitsSection) { 49 + limitsSection.style.display = 'none'; 50 + } 51 + 52 + if (previewFlavor) { 53 + const banner = screen.querySelector('.setup-preview-banner') as HTMLElement | null; 54 + if (banner) { 55 + banner.style.display = ''; 56 + banner.textContent = `Preview mode — viewing as "${previewFlavor}" instance`; 57 + } 58 + } 59 + 60 + screen.style.display = ''; 61 + } 62 + 63 + export function initSetupFlow(onContinue: () => void): void { 64 + const screen = document.getElementById('setup-screen'); 65 + if (!screen) return; 66 + 67 + const continueBtn = screen.querySelector('#setup-continue'); 68 + if (continueBtn) { 69 + continueBtn.addEventListener('click', () => { 70 + screen.style.display = 'none'; 71 + markSetupSeen(); 72 + onContinue(); 73 + }); 74 + } 75 + } 76 + 77 + function escapeHtml(s: string): string { 78 + return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); 79 + }
+199
tests/setup-flow.test.ts
··· 1 + /** 2 + * @vitest-environment jsdom 3 + */ 4 + import { describe, it, expect, beforeEach, vi } from 'vitest'; 5 + 6 + vi.mock('../src/lib/instance-info.js', async () => { 7 + const actual = await vi.importActual<typeof import('../src/lib/instance-info.js')>('../src/lib/instance-info.js'); 8 + return { 9 + ...actual, 10 + getInstanceInfo: vi.fn(), 11 + }; 12 + }); 13 + 14 + import { getInstanceInfo, describeInstance, type InstanceInfo } from '../src/lib/instance-info.js'; 15 + import { getPreviewFlavor, renderSetupScreen, initSetupFlow } from '../src/lib/setup-flow.js'; 16 + 17 + const mockGetInstanceInfo = vi.mocked(getInstanceInfo); 18 + 19 + function makeInfo(overrides: Partial<InstanceInfo> = {}): InstanceInfo { 20 + return { 21 + flavor: 'public', 22 + operator: null, 23 + pds: null, 24 + features: { sync: false, sharing: false, ai: false }, 25 + notice: null, 26 + accessControl: null, 27 + ...overrides, 28 + }; 29 + } 30 + 31 + function setupDOM(): void { 32 + document.body.innerHTML = ` 33 + <div class="modal-backdrop" id="setup-screen" style="display:none;"> 34 + <div class="modal setup-modal"> 35 + <div class="setup-preview-banner" style="display:none;"></div> 36 + <h2 class="setup-title"></h2> 37 + <p class="setup-summary"></p> 38 + <div class="setup-capabilities-section"> 39 + <h3>What you can do</h3> 40 + <ul class="setup-capabilities"></ul> 41 + </div> 42 + <div class="setup-limitations-section"> 43 + <h3>Current limitations</h3> 44 + <ul class="setup-limitations"></ul> 45 + </div> 46 + <div class="setup-disclaimer"> 47 + <p>All data is stored locally in your browser. Atmosphere Mail LLC does not store, access, or back up your documents.</p> 48 + </div> 49 + <button id="setup-continue" class="btn-primary">Continue to Sign In</button> 50 + </div> 51 + </div> 52 + <div class="modal-backdrop" id="username-modal" style="display:none;"> 53 + <div class="modal username-modal"> 54 + <input type="text" id="username-input" /> 55 + <button id="username-skip">Skip</button> 56 + <button id="username-confirm">Sign In</button> 57 + </div> 58 + </div> 59 + `; 60 + } 61 + 62 + describe('getPreviewFlavor', () => { 63 + it('returns null when no preview-flavor param', () => { 64 + expect(getPreviewFlavor('')).toBeNull(); 65 + }); 66 + 67 + it('returns pds-operator when param is set', () => { 68 + expect(getPreviewFlavor('?preview-flavor=pds-operator')).toBe('pds-operator'); 69 + }); 70 + 71 + it('returns self-hosted when param is set', () => { 72 + expect(getPreviewFlavor('?preview-flavor=self-hosted')).toBe('self-hosted'); 73 + }); 74 + 75 + it('returns public when param is set', () => { 76 + expect(getPreviewFlavor('?preview-flavor=public')).toBe('public'); 77 + }); 78 + 79 + it('returns null for invalid flavor value', () => { 80 + expect(getPreviewFlavor('?preview-flavor=invalid')).toBeNull(); 81 + }); 82 + }); 83 + 84 + describe('renderSetupScreen', () => { 85 + beforeEach(() => { 86 + setupDOM(); 87 + vi.clearAllMocks(); 88 + }); 89 + 90 + it('populates setup screen with public flavor content', async () => { 91 + const info = makeInfo(); 92 + mockGetInstanceInfo.mockResolvedValue(info); 93 + 94 + await renderSetupScreen(); 95 + 96 + const title = document.querySelector('.setup-title')!; 97 + expect(title.textContent).toBe('Atmosphere Office'); 98 + 99 + const summary = document.querySelector('.setup-summary')!; 100 + expect(summary.textContent).toContain('local-only'); 101 + 102 + const caps = document.querySelectorAll('.setup-capabilities li'); 103 + expect(caps.length).toBeGreaterThan(0); 104 + 105 + const limits = document.querySelectorAll('.setup-limitations li'); 106 + expect(limits.length).toBeGreaterThan(0); 107 + }); 108 + 109 + it('populates setup screen with pds-operator content', async () => { 110 + const info = makeInfo({ 111 + flavor: 'pds-operator', 112 + operator: 'Alice', 113 + pds: 'pds.example.com', 114 + }); 115 + mockGetInstanceInfo.mockResolvedValue(info); 116 + 117 + await renderSetupScreen(); 118 + 119 + const title = document.querySelector('.setup-title')!; 120 + expect(title.textContent).toBe('Hosted by Alice'); 121 + 122 + const summary = document.querySelector('.setup-summary')!; 123 + expect(summary.textContent).toContain('pds.example.com'); 124 + }); 125 + 126 + it('populates setup screen with self-hosted content', async () => { 127 + const info = makeInfo({ flavor: 'self-hosted' }); 128 + mockGetInstanceInfo.mockResolvedValue(info); 129 + 130 + await renderSetupScreen(); 131 + 132 + const title = document.querySelector('.setup-title')!; 133 + expect(title.textContent).toBe('Self-Hosted Instance'); 134 + }); 135 + 136 + it('shows setup screen (sets display)', async () => { 137 + mockGetInstanceInfo.mockResolvedValue(makeInfo()); 138 + await renderSetupScreen(); 139 + expect(document.getElementById('setup-screen')!.style.display).toBe(''); 140 + }); 141 + 142 + it('hides limitations section when there are none', async () => { 143 + const info = makeInfo({ 144 + flavor: 'pds-operator', 145 + features: { sync: true, sharing: true, ai: true }, 146 + }); 147 + mockGetInstanceInfo.mockResolvedValue(info); 148 + 149 + await renderSetupScreen(); 150 + 151 + const limitsSection = document.querySelector('.setup-limitations-section') as HTMLElement; 152 + expect(limitsSection.style.display).toBe('none'); 153 + }); 154 + 155 + it('shows preview banner when preview flavor is active', async () => { 156 + mockGetInstanceInfo.mockResolvedValue(makeInfo()); 157 + 158 + await renderSetupScreen('pds-operator'); 159 + 160 + const banner = document.querySelector('.setup-preview-banner') as HTMLElement; 161 + expect(banner.style.display).toBe(''); 162 + expect(banner.textContent).toContain('pds-operator'); 163 + 164 + const title = document.querySelector('.setup-title')!; 165 + expect(title.textContent).toBe('PDS Operator Instance'); 166 + }); 167 + }); 168 + 169 + describe('initSetupFlow', () => { 170 + beforeEach(() => { 171 + setupDOM(); 172 + }); 173 + 174 + it('calls onContinue and hides setup screen when continue is clicked', () => { 175 + const onContinue = vi.fn(); 176 + initSetupFlow(onContinue); 177 + 178 + document.getElementById('setup-screen')!.style.display = ''; 179 + document.getElementById('setup-continue')!.click(); 180 + 181 + expect(onContinue).toHaveBeenCalledOnce(); 182 + expect(document.getElementById('setup-screen')!.style.display).toBe('none'); 183 + }); 184 + }); 185 + 186 + describe('non-dismissible login modal', () => { 187 + beforeEach(() => { 188 + setupDOM(); 189 + }); 190 + 191 + it('username modal backdrop should not have click-to-close in allowlist mode', () => { 192 + const modal = document.getElementById('username-modal')!; 193 + modal.style.display = ''; 194 + 195 + modal.dispatchEvent(new MouseEvent('click', { bubbles: true })); 196 + 197 + expect(modal.style.display).toBe(''); 198 + }); 199 + });