experiments in a post-browser web
10
fork

Configure Feed

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

feat(sheets): add widget sheet extension with freeform layout

+841
+50
extensions/sheets/background.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"> 6 + <title>Sheets Extension</title> 7 + </head> 8 + <body> 9 + <script type="module"> 10 + import extension from './background.js'; 11 + 12 + const api = window.app; 13 + const extId = extension.id; 14 + 15 + console.log(`[ext:${extId}] background.html loaded`); 16 + 17 + // Signal ready to main process 18 + api.publish('ext:ready', { 19 + id: extId, 20 + manifest: { 21 + id: extension.id, 22 + labels: extension.labels, 23 + version: '1.0.0' 24 + } 25 + }, api.scopes.SYSTEM); 26 + 27 + // Initialize extension 28 + if (extension.init) { 29 + console.log(`[ext:${extId}] calling init()`); 30 + extension.init(); 31 + } 32 + 33 + // Handle shutdown request from main process 34 + api.subscribe('app:shutdown', () => { 35 + console.log(`[ext:${extId}] received shutdown`); 36 + if (extension.uninit) { 37 + extension.uninit(); 38 + } 39 + }, api.scopes.SYSTEM); 40 + 41 + // Handle extension-specific shutdown 42 + api.subscribe(`ext:${extId}:shutdown`, () => { 43 + console.log(`[ext:${extId}] received extension shutdown`); 44 + if (extension.uninit) { 45 + extension.uninit(); 46 + } 47 + }, api.scopes.SYSTEM); 48 + </script> 49 + </body> 50 + </html>
+186
extensions/sheets/background.js
··· 1 + /** 2 + * Sheets Extension Background Script 3 + * 4 + * Widget sheets with freeform card layouts hosting webviews. 5 + * 6 + * Runs in isolated extension process (peek://ext/sheets/background.html) 7 + */ 8 + 9 + import { id, labels, schemas, storageKeys, defaults } from './config.js'; 10 + 11 + const api = window.app; 12 + const debug = api.debug; 13 + 14 + console.log('[ext:sheets] background', labels.name); 15 + 16 + // Extension content is served from peek://sheets/ (hybrid mode) 17 + const sheetPageUrl = 'peek://sheets/sheet.html'; 18 + 19 + /** 20 + * Get all sheet configs from extension_settings 21 + */ 22 + const listSheets = async () => { 23 + const result = await api.settings.get(); 24 + if (!result.success || !result.data) return []; 25 + 26 + const sheets = []; 27 + for (const [key, value] of Object.entries(result.data)) { 28 + if (key.startsWith('sheet:')) { 29 + sheets.push({ key, ...value }); 30 + } 31 + } 32 + return sheets; 33 + }; 34 + 35 + /** 36 + * Create a new sheet config 37 + */ 38 + const createSheet = async (name) => { 39 + const sheetId = crypto.randomUUID().slice(0, 8); 40 + const key = `sheet:${sheetId}`; 41 + const config = { 42 + version: 1, 43 + name: name || 'Untitled Sheet', 44 + createdAt: Date.now(), 45 + items: [] 46 + }; 47 + 48 + await api.settings.setKey(key, config); 49 + return { sheetId, key, config }; 50 + }; 51 + 52 + /** 53 + * Open a sheet window 54 + */ 55 + const openSheet = (sheetId, name) => { 56 + const url = `${sheetPageUrl}?sheetId=${sheetId}`; 57 + const params = { 58 + key: url, 59 + trackingSource: 'sheets', 60 + trackingSourceId: sheetId 61 + }; 62 + 63 + api.window.open(url, params) 64 + .then(win => { 65 + debug && console.log('[ext:sheets] Sheet opened:', sheetId, name); 66 + }) 67 + .catch(error => { 68 + console.error('[ext:sheets] Failed to open sheet:', error); 69 + }); 70 + }; 71 + 72 + // ===== Command definitions ===== 73 + 74 + const commandDefinitions = [ 75 + { 76 + name: 'sheets', 77 + description: 'List all widget sheets', 78 + execute: async (ctx) => { 79 + const sheets = await listSheets(); 80 + if (sheets.length === 0) { 81 + console.log('[ext:sheets] No sheets found'); 82 + return { success: true, message: 'No sheets found' }; 83 + } 84 + 85 + // Return sheet names as results for the cmd palette to display 86 + return { 87 + success: true, 88 + results: sheets.map(s => ({ 89 + name: s.name, 90 + description: `Created ${new Date(s.createdAt).toLocaleDateString()}`, 91 + execute: () => { 92 + const sheetId = s.key.replace('sheet:', ''); 93 + openSheet(sheetId, s.name); 94 + } 95 + })) 96 + }; 97 + } 98 + }, 99 + { 100 + name: 'new sheet', 101 + description: 'Create a new widget sheet', 102 + execute: async (ctx) => { 103 + const name = ctx?.args || 'Untitled Sheet'; 104 + console.log('[ext:sheets] Creating new sheet:', name); 105 + const { sheetId } = await createSheet(name); 106 + openSheet(sheetId, name); 107 + } 108 + }, 109 + { 110 + name: 'open sheet', 111 + description: 'Open an existing widget sheet by name', 112 + execute: async (ctx) => { 113 + const sheets = await listSheets(); 114 + if (sheets.length === 0) { 115 + console.log('[ext:sheets] No sheets to open'); 116 + return { success: true, message: 'No sheets found' }; 117 + } 118 + 119 + const query = (ctx?.args || '').toLowerCase(); 120 + 121 + // If a name was provided, try to match it 122 + if (query) { 123 + const match = sheets.find(s => s.name.toLowerCase() === query) || 124 + sheets.find(s => s.name.toLowerCase().includes(query)); 125 + if (match) { 126 + const sheetId = match.key.replace('sheet:', ''); 127 + openSheet(sheetId, match.name); 128 + return; 129 + } 130 + } 131 + 132 + // Return sheet list for selection 133 + return { 134 + success: true, 135 + results: sheets.map(s => ({ 136 + name: s.name, 137 + description: `Created ${new Date(s.createdAt).toLocaleDateString()}`, 138 + execute: () => { 139 + const sheetId = s.key.replace('sheet:', ''); 140 + openSheet(sheetId, s.name); 141 + } 142 + })) 143 + }; 144 + } 145 + } 146 + ]; 147 + 148 + // ===== Registration ===== 149 + 150 + let registeredCommands = []; 151 + 152 + const initCommands = async () => { 153 + commandDefinitions.forEach(cmd => { 154 + api.commands.register(cmd); 155 + registeredCommands.push(cmd.name); 156 + }); 157 + console.log('[ext:sheets] Registered commands:', registeredCommands); 158 + }; 159 + 160 + const uninitCommands = () => { 161 + registeredCommands.forEach(name => { 162 + api.commands.unregister(name); 163 + }); 164 + registeredCommands = []; 165 + console.log('[ext:sheets] Unregistered commands'); 166 + }; 167 + 168 + const init = async () => { 169 + console.log('[ext:sheets] init'); 170 + initCommands(); 171 + }; 172 + 173 + const uninit = () => { 174 + console.log('[ext:sheets] uninit'); 175 + uninitCommands(); 176 + }; 177 + 178 + export default { 179 + defaults, 180 + id, 181 + init, 182 + uninit, 183 + labels, 184 + schemas, 185 + storageKeys 186 + };
+19
extensions/sheets/config.js
··· 1 + const id = 'sheets'; 2 + 3 + const labels = { 4 + name: 'Sheets', 5 + }; 6 + 7 + const schemas = {}; 8 + 9 + const storageKeys = {}; 10 + 11 + const defaults = {}; 12 + 13 + export { 14 + id, 15 + labels, 16 + schemas, 17 + storageKeys, 18 + defaults 19 + };
+9
extensions/sheets/manifest.json
··· 1 + { 2 + "id": "sheets", 3 + "shortname": "sheets", 4 + "name": "Sheets", 5 + "description": "Widget sheets with freeform card layouts hosting webviews", 6 + "version": "1.0.0", 7 + "background": "background.html", 8 + "builtin": true 9 + }
+8
extensions/sheets/settings-schema.json
··· 1 + { 2 + "prefs": { 3 + "type": "object", 4 + "properties": {} 5 + }, 6 + "storageKeys": {}, 7 + "defaults": {} 8 + }
+200
extensions/sheets/sheet.css
··· 1 + /* Import theme variables */ 2 + @import url('peek://theme/variables.css'); 3 + 4 + * { 5 + box-sizing: border-box; 6 + margin: 0; 7 + padding: 0; 8 + } 9 + 10 + html { 11 + font-family: var(--theme-font-sans); 12 + -webkit-font-smoothing: antialiased; 13 + font-size: 14px; 14 + line-height: 1.5; 15 + } 16 + 17 + body { 18 + background: var(--base00); 19 + color: var(--base05); 20 + min-height: 100vh; 21 + } 22 + 23 + /* Sheet header */ 24 + .sheet-header { 25 + display: flex; 26 + align-items: center; 27 + justify-content: space-between; 28 + padding: 16px 24px; 29 + } 30 + 31 + .sheet-title { 32 + font-size: 18px; 33 + font-weight: 600; 34 + color: var(--base05); 35 + } 36 + 37 + .sheet-actions { 38 + display: flex; 39 + gap: 8px; 40 + } 41 + 42 + /* Buttons */ 43 + .btn { 44 + padding: 6px 14px; 45 + border: 1px solid var(--base02); 46 + border-radius: 6px; 47 + background: var(--base01); 48 + color: var(--base05); 49 + font-size: 13px; 50 + cursor: pointer; 51 + font-family: inherit; 52 + } 53 + 54 + .btn:hover { 55 + background: var(--base02); 56 + } 57 + 58 + .btn-edit.active { 59 + background: var(--base0D); 60 + color: var(--base00); 61 + border-color: var(--base0D); 62 + } 63 + 64 + /* Cards container */ 65 + .cards { 66 + padding: 0 24px 24px 24px; 67 + } 68 + 69 + /* Card styling */ 70 + peek-card { 71 + --peek-card-bg: var(--base01); 72 + --peek-card-hover-bg: var(--base02); 73 + --peek-card-border: var(--base02); 74 + --peek-card-radius: 8px; 75 + --peek-card-padding: 0; 76 + --peek-card-gap: 0; 77 + overflow: hidden; 78 + } 79 + 80 + /* Card header in edit mode */ 81 + .card-header { 82 + display: flex; 83 + align-items: center; 84 + justify-content: space-between; 85 + padding: 4px 8px; 86 + background: var(--base01); 87 + border-bottom: 1px solid var(--base02); 88 + min-height: 28px; 89 + } 90 + 91 + .card-header-title { 92 + font-size: 11px; 93 + color: var(--base04); 94 + white-space: nowrap; 95 + overflow: hidden; 96 + text-overflow: ellipsis; 97 + flex: 1; 98 + min-width: 0; 99 + } 100 + 101 + .card-remove-btn { 102 + display: none; 103 + width: 20px; 104 + height: 20px; 105 + border: none; 106 + border-radius: 4px; 107 + background: transparent; 108 + color: var(--base04); 109 + cursor: pointer; 110 + font-size: 14px; 111 + line-height: 1; 112 + flex-shrink: 0; 113 + padding: 0; 114 + text-align: center; 115 + } 116 + 117 + .card-remove-btn:hover { 118 + background: var(--base08); 119 + color: var(--base00); 120 + } 121 + 122 + /* Show remove button in edit mode */ 123 + body.editing .card-remove-btn { 124 + display: block; 125 + } 126 + 127 + /* Webview inside card */ 128 + .card-webview { 129 + width: 100%; 130 + height: calc(100% - 28px); 131 + border: none; 132 + } 133 + 134 + /* Disable webview interaction in edit mode */ 135 + body.editing .card-webview { 136 + pointer-events: none; 137 + } 138 + 139 + /* Add dialog */ 140 + .add-dialog { 141 + position: fixed; 142 + inset: 0; 143 + z-index: 1000; 144 + } 145 + 146 + .add-dialog-backdrop { 147 + position: absolute; 148 + inset: 0; 149 + background: rgba(0, 0, 0, 0.5); 150 + } 151 + 152 + .add-dialog-content { 153 + position: absolute; 154 + top: 50%; 155 + left: 50%; 156 + transform: translate(-50%, -50%); 157 + background: var(--base01); 158 + border: 1px solid var(--base02); 159 + border-radius: 12px; 160 + padding: 24px; 161 + min-width: 400px; 162 + } 163 + 164 + .add-dialog-content h2 { 165 + font-size: 16px; 166 + font-weight: 600; 167 + margin-bottom: 16px; 168 + color: var(--base05); 169 + } 170 + 171 + .add-dialog-input { 172 + width: 100%; 173 + padding: 8px 12px; 174 + border: 1px solid var(--base02); 175 + border-radius: 6px; 176 + background: var(--base00); 177 + color: var(--base05); 178 + font-size: 14px; 179 + font-family: inherit; 180 + margin-bottom: 16px; 181 + } 182 + 183 + .add-dialog-input:focus { 184 + outline: none; 185 + border-color: var(--base0D); 186 + } 187 + 188 + .add-dialog-buttons { 189 + display: flex; 190 + justify-content: flex-end; 191 + gap: 8px; 192 + } 193 + 194 + /* Empty state */ 195 + .empty-state { 196 + text-align: center; 197 + padding: 48px 24px; 198 + color: var(--base03); 199 + font-size: 15px; 200 + }
+58
extensions/sheets/sheet.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"> 6 + <meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1"> 7 + <title>Sheet</title> 8 + <link rel="stylesheet" type="text/css" href="sheet.css"> 9 + 10 + <!-- Import map for resolving bare module specifiers --> 11 + <script type="importmap"> 12 + { 13 + "imports": { 14 + "lit": "peek://node_modules/lit/index.js", 15 + "lit/": "peek://node_modules/lit/", 16 + "lit-html": "peek://node_modules/lit-html/lit-html.js", 17 + "lit-html/": "peek://node_modules/lit-html/", 18 + "lit-element": "peek://node_modules/lit-element/lit-element.js", 19 + "lit-element/": "peek://node_modules/lit-element/", 20 + "@lit/reactive-element": "peek://node_modules/@lit/reactive-element/reactive-element.js", 21 + "@lit/reactive-element/": "peek://node_modules/@lit/reactive-element/" 22 + } 23 + } 24 + </script> 25 + 26 + <!-- Import peek-components --> 27 + <script type="module"> 28 + import 'peek://app/components/peek-card.js'; 29 + import 'peek://app/components/peek-grid.js'; 30 + import 'peek://app/components/peek-grid-toolbar.js'; 31 + </script> 32 + </head> 33 + <body> 34 + <div class="sheet-header"> 35 + <h1 class="sheet-title">Sheet</h1> 36 + <div class="sheet-actions"> 37 + <button class="btn btn-add" style="display:none;">+ Add Card</button> 38 + <button class="btn btn-edit">Edit</button> 39 + </div> 40 + </div> 41 + 42 + <peek-grid class="cards" view-mode="freeform" freeform-snap="20" gap="12"></peek-grid> 43 + 44 + <div class="add-dialog" style="display:none;"> 45 + <div class="add-dialog-backdrop"></div> 46 + <div class="add-dialog-content"> 47 + <h2>Add Card</h2> 48 + <input type="text" class="add-dialog-input" placeholder="Enter URL (peek:// or https://)" /> 49 + <div class="add-dialog-buttons"> 50 + <button class="btn btn-cancel">Cancel</button> 51 + <button class="btn btn-confirm">Add</button> 52 + </div> 53 + </div> 54 + </div> 55 + 56 + <script type="module" src="sheet.js"></script> 57 + </body> 58 + </html>
+311
extensions/sheets/sheet.js
··· 1 + /** 2 + * Sheet - Widget sheet with freeform card layout 3 + * 4 + * Each card hosts a webview pointing to a URL (peek:// or https://). 5 + * Uses peek-grid in freeform mode with drag/resize in edit mode. 6 + */ 7 + 8 + const api = window.app; 9 + const debug = api.debug; 10 + 11 + let sheetId = null; 12 + let sheetConfig = null; 13 + let editing = false; 14 + 15 + /** 16 + * Parse sheetId from URL query params 17 + */ 18 + const getSheetIdFromUrl = () => { 19 + const params = new URLSearchParams(window.location.search); 20 + return params.get('sheetId'); 21 + }; 22 + 23 + /** 24 + * Load sheet config from extension_settings 25 + */ 26 + const loadSheetConfig = async () => { 27 + const key = `sheet:${sheetId}`; 28 + const result = await api.settings.getKey(key); 29 + if (result.success && result.data) { 30 + return result.data; 31 + } 32 + return null; 33 + }; 34 + 35 + /** 36 + * Save sheet config to extension_settings 37 + */ 38 + const saveSheetConfig = async () => { 39 + if (!sheetConfig) return; 40 + const key = `sheet:${sheetId}`; 41 + await api.settings.setKey(key, sheetConfig); 42 + debug && console.log('[sheets] Config saved:', key); 43 + }; 44 + 45 + /** 46 + * Create a card element with a webview for a sheet item 47 + */ 48 + const createCard = (item) => { 49 + const card = document.createElement('peek-card'); 50 + card.id = item.id; 51 + card.dataset.id = item.id; 52 + 53 + // Header with URL title and remove button 54 + const header = document.createElement('div'); 55 + header.className = 'card-header'; 56 + header.slot = 'header'; 57 + 58 + const title = document.createElement('span'); 59 + title.className = 'card-header-title'; 60 + try { 61 + title.textContent = new URL(item.url).hostname || item.url; 62 + } catch { 63 + title.textContent = item.url; 64 + } 65 + header.appendChild(title); 66 + 67 + const removeBtn = document.createElement('button'); 68 + removeBtn.className = 'card-remove-btn'; 69 + removeBtn.textContent = '\u00d7'; 70 + removeBtn.title = 'Remove card'; 71 + removeBtn.addEventListener('click', (e) => { 72 + e.stopPropagation(); 73 + removeCard(item.id); 74 + }); 75 + header.appendChild(removeBtn); 76 + 77 + card.appendChild(header); 78 + 79 + // Webview 80 + const webview = document.createElement('webview'); 81 + webview.className = 'card-webview'; 82 + webview.src = item.url; 83 + card.appendChild(webview); 84 + 85 + return card; 86 + }; 87 + 88 + /** 89 + * Render all cards from config 90 + */ 91 + const renderCards = () => { 92 + const grid = document.querySelector('peek-grid.cards'); 93 + grid.innerHTML = ''; 94 + 95 + if (!sheetConfig || sheetConfig.items.length === 0) { 96 + const empty = document.createElement('div'); 97 + empty.className = 'empty-state'; 98 + empty.textContent = editing 99 + ? 'No cards yet. Click "+ Add Card" to add one.' 100 + : 'This sheet is empty. Click "Edit" to add cards.'; 101 + grid.appendChild(empty); 102 + return; 103 + } 104 + 105 + // Build freeform layout map from config 106 + const layout = {}; 107 + for (const item of sheetConfig.items) { 108 + layout[item.id] = { 109 + x: item.x, 110 + y: item.y, 111 + w: item.width, 112 + h: item.height 113 + }; 114 + } 115 + 116 + // Create card elements 117 + for (const item of sheetConfig.items) { 118 + const card = createCard(item); 119 + grid.appendChild(card); 120 + } 121 + 122 + // Set freeform layout on grid 123 + grid.freeformLayout = layout; 124 + }; 125 + 126 + /** 127 + * Add a new card with a URL 128 + */ 129 + const addCard = async (url) => { 130 + if (!url) return; 131 + 132 + const id = crypto.randomUUID().slice(0, 8); 133 + 134 + // Auto-place: find a position that doesn't overlap 135 + const existingItems = sheetConfig.items; 136 + const cols = 3; 137 + const defaultW = 300; 138 + const defaultH = 200; 139 + const gap = 12; 140 + const col = existingItems.length % cols; 141 + const row = Math.floor(existingItems.length / cols); 142 + 143 + const newItem = { 144 + id, 145 + url, 146 + x: col * (defaultW + gap), 147 + y: row * (defaultH + gap), 148 + width: defaultW, 149 + height: defaultH 150 + }; 151 + 152 + sheetConfig.items.push(newItem); 153 + await saveSheetConfig(); 154 + renderCards(); 155 + updateEditState(); 156 + }; 157 + 158 + /** 159 + * Remove a card by id 160 + */ 161 + const removeCard = async (cardId) => { 162 + sheetConfig.items = sheetConfig.items.filter(item => item.id !== cardId); 163 + await saveSheetConfig(); 164 + renderCards(); 165 + updateEditState(); 166 + }; 167 + 168 + /** 169 + * Toggle edit mode 170 + */ 171 + const toggleEdit = () => { 172 + editing = !editing; 173 + updateEditState(); 174 + }; 175 + 176 + /** 177 + * Apply edit mode state to UI 178 + */ 179 + const updateEditState = () => { 180 + const grid = document.querySelector('peek-grid.cards'); 181 + const editBtn = document.querySelector('.btn-edit'); 182 + const addBtn = document.querySelector('.btn-add'); 183 + 184 + grid.freeformEditing = editing; 185 + 186 + if (editing) { 187 + document.body.classList.add('editing'); 188 + editBtn.textContent = 'Done'; 189 + editBtn.classList.add('active'); 190 + addBtn.style.display = ''; 191 + } else { 192 + document.body.classList.remove('editing'); 193 + editBtn.textContent = 'Edit'; 194 + editBtn.classList.remove('active'); 195 + addBtn.style.display = 'none'; 196 + } 197 + }; 198 + 199 + /** 200 + * Show URL input dialog 201 + */ 202 + const showAddDialog = () => { 203 + const dialog = document.querySelector('.add-dialog'); 204 + const input = dialog.querySelector('.add-dialog-input'); 205 + dialog.style.display = ''; 206 + input.value = ''; 207 + input.focus(); 208 + }; 209 + 210 + /** 211 + * Hide URL input dialog 212 + */ 213 + const hideAddDialog = () => { 214 + const dialog = document.querySelector('.add-dialog'); 215 + dialog.style.display = 'none'; 216 + }; 217 + 218 + /** 219 + * Handle dialog confirm 220 + */ 221 + const confirmAddDialog = () => { 222 + const input = document.querySelector('.add-dialog-input'); 223 + const url = input.value.trim(); 224 + if (url) { 225 + addCard(url); 226 + } 227 + hideAddDialog(); 228 + }; 229 + 230 + /** 231 + * Set up event listeners 232 + */ 233 + const setupEvents = () => { 234 + // Edit toggle 235 + document.querySelector('.btn-edit').addEventListener('click', toggleEdit); 236 + 237 + // Add card button 238 + document.querySelector('.btn-add').addEventListener('click', showAddDialog); 239 + 240 + // Add dialog 241 + document.querySelector('.add-dialog-backdrop').addEventListener('click', hideAddDialog); 242 + document.querySelector('.btn-cancel').addEventListener('click', hideAddDialog); 243 + document.querySelector('.btn-confirm').addEventListener('click', confirmAddDialog); 244 + 245 + // Enter key in dialog input 246 + document.querySelector('.add-dialog-input').addEventListener('keydown', (e) => { 247 + if (e.key === 'Enter') { 248 + e.preventDefault(); 249 + confirmAddDialog(); 250 + } 251 + if (e.key === 'Escape') { 252 + e.preventDefault(); 253 + hideAddDialog(); 254 + } 255 + }); 256 + 257 + // Listen for freeform layout changes from peek-grid (drag/resize) 258 + const grid = document.querySelector('peek-grid.cards'); 259 + grid.addEventListener('freeform-layout-change', (e) => { 260 + const { layout } = e.detail; 261 + debug && console.log('[sheets] Layout changed:', layout); 262 + 263 + // Update config items with new positions 264 + for (const item of sheetConfig.items) { 265 + const bounds = layout[item.id]; 266 + if (bounds) { 267 + item.x = bounds.x; 268 + item.y = bounds.y; 269 + item.width = bounds.w; 270 + item.height = bounds.h; 271 + } 272 + } 273 + 274 + saveSheetConfig(); 275 + }); 276 + }; 277 + 278 + /** 279 + * Initialize sheet 280 + */ 281 + const init = async () => { 282 + sheetId = getSheetIdFromUrl(); 283 + if (!sheetId) { 284 + console.error('[sheets] No sheetId in URL'); 285 + return; 286 + } 287 + 288 + debug && console.log('[sheets] Loading sheet:', sheetId); 289 + 290 + sheetConfig = await loadSheetConfig(); 291 + if (!sheetConfig) { 292 + console.error('[sheets] Sheet config not found:', sheetId); 293 + sheetConfig = { 294 + version: 1, 295 + name: 'Untitled Sheet', 296 + createdAt: Date.now(), 297 + items: [] 298 + }; 299 + } 300 + 301 + // Set page title 302 + const titleEl = document.querySelector('.sheet-title'); 303 + titleEl.textContent = sheetConfig.name; 304 + document.title = sheetConfig.name; 305 + 306 + setupEvents(); 307 + renderCards(); 308 + updateEditState(); 309 + }; 310 + 311 + document.addEventListener('DOMContentLoaded', init);