A Chrome extension to quickly capture URLs into Semble Collections at https://semble.so semble.so
at-proto semble chrome-extension
6
fork

Configure Feed

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

Initial as-semble 🎺

+1596
+155
.gitignore
··· 1 + # Logs 2 + logs 3 + *.log 4 + npm-debug.log* 5 + yarn-debug.log* 6 + yarn-error.log* 7 + lerna-debug.log* 8 + 9 + # Diagnostic reports (https://nodejs.org/api/report.html) 10 + report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 + 12 + # Runtime data 13 + pids 14 + *.pid 15 + *.seed 16 + *.pid.lock 17 + 18 + # Directory for instrumented libs generated by jscoverage/JSCover 19 + lib-cov 20 + 21 + # Coverage directory used by tools like istanbul 22 + coverage 23 + *.lcov 24 + 25 + # nyc test coverage 26 + .nyc_output 27 + 28 + # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 + .grunt 30 + 31 + # Bower dependency directory (https://bower.io/) 32 + bower_components 33 + 34 + # node-waf configuration 35 + .lock-wscript 36 + 37 + # Compiled binary addons (https://nodejs.org/api/addons.html) 38 + build/Release 39 + 40 + # Dependency directories 41 + node_modules/ 42 + jspm_packages/ 43 + 44 + # Snowpack dependency directory (https://snowpack.dev/) 45 + web_modules/ 46 + 47 + # TypeScript cache 48 + *.tsbuildinfo 49 + 50 + # Optional npm cache directory 51 + .npm 52 + 53 + # Optional eslint cache 54 + .eslintcache 55 + 56 + # Optional stylelint cache 57 + .stylelintcache 58 + 59 + # Optional REPL history 60 + .node_repl_history 61 + 62 + # Output of 'npm pack' 63 + *.tgz 64 + 65 + # Yarn Integrity file 66 + .yarn-integrity 67 + 68 + # dotenv environment variable files 69 + .env 70 + .env.* 71 + !.env.example 72 + 73 + # parcel-bundler cache (https://parceljs.org/) 74 + .cache 75 + .parcel-cache 76 + 77 + # Next.js build output 78 + .next 79 + out 80 + 81 + # Nuxt.js build / generate output 82 + .nuxt 83 + dist 84 + 85 + # Gatsby files 86 + .cache/ 87 + # Comment in the public line in if your project uses Gatsby and not Next.js 88 + # https://nextjs.org/blog/next-9-1#public-directory-support 89 + # public 90 + 91 + # vuepress build output 92 + .vuepress/dist 93 + 94 + # vuepress v2.x temp and cache directory 95 + .temp 96 + .cache 97 + 98 + # Sveltekit cache directory 99 + .svelte-kit/ 100 + 101 + # vitepress build output 102 + **/.vitepress/dist 103 + 104 + # vitepress cache directory 105 + **/.vitepress/cache 106 + 107 + # Docusaurus cache and generated files 108 + .docusaurus 109 + 110 + # Serverless directories 111 + .serverless/ 112 + 113 + # FuseBox cache 114 + .fusebox/ 115 + 116 + # DynamoDB Local files 117 + .dynamodb/ 118 + 119 + # Firebase cache directory 120 + .firebase/ 121 + 122 + # TernJS port file 123 + .tern-port 124 + 125 + # Stores VSCode versions used for testing VSCode extensions 126 + .vscode-test 127 + 128 + # yarn v3 129 + .pnp.* 130 + .yarn/* 131 + !.yarn/patches 132 + !.yarn/plugins 133 + !.yarn/releases 134 + !.yarn/sdks 135 + !.yarn/versions 136 + 137 + # Vite logs files 138 + vite.config.js.timestamp-* 139 + vite.config.ts.timestamp-* 140 + 141 + # Chrome Extension specific 142 + *.crx 143 + *.pem 144 + extension.zip 145 + 146 + # IDE files 147 + .vscode/ 148 + .idea/ 149 + *.swp 150 + *.swo 151 + *~ 152 + 153 + # OS files 154 + .DS_Store 155 + Thumbs.db
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2025 Barry Prendergast 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+106
README.md
··· 1 + # Semble Quick Capture 2 + 3 + A Chrome extension for quickly capturing and organizing URLs into Semble Collections using your Bluesky account. 4 + 5 + ## About 6 + 7 + Save interesting web pages directly into your Semble Collections. Simply click the extension icon, add an optional note, select a collection, and save—all without leaving your current page. 8 + 9 + ## Features 10 + 11 + - **One-Click Capture**: Save the current tab's URL with a single click 12 + - **Bluesky Integration**: Secure authentication using Bluesky App Passwords 13 + - **Collection Management**: Choose from your existing Semble Collections 14 + - **Add Notes**: Include optional notes with your captured URLs 15 + - **Clean Interface**: Simple, intuitive design that stays out of your way 16 + 17 + ## Installation 18 + 19 + ### From Source 20 + 21 + 1. Clone this repository: 22 + ```bash 23 + git clone git@tangled.sh:renderg.host/semble-chrome-extension 24 + cd semble-chrome-extension 25 + ``` 26 + 27 + 2. Open Chrome and navigate to `chrome://extensions/` 28 + 29 + 3. Enable "Developer mode" using the toggle in the top right corner 30 + 31 + 4. Click "Load unpacked" and select the extension directory 32 + 33 + ### From Chrome Web Store 34 + 35 + `Coming soon!` 36 + 37 + ## Usage 38 + 39 + ### First Time Setup 40 + 41 + 1. Click the Semble extension icon in your browser toolbar 42 + 2. Sign in with your Bluesky handle and App Password 43 + - Don't have an App Password? Generate one at [Bluesky Settings](https://bsky.app/settings/app-passwords) 44 + - Don't have a Bluesky account? [Sign up here](https://bsky.app) 45 + 46 + ### Capturing URLs 47 + 48 + 1. Navigate to any webpage you want to save 49 + 2. Click the Semble extension icon 50 + 3. The current URL will be automatically captured 51 + 4. (Optional) Add a note about why you're saving this page 52 + 5. Select a collection from the dropdown 53 + 6. Click "Add to Collection" 54 + 55 + ### Sign Out 56 + 57 + Click the "Sign Out" button in the extension popup to securely log out of your account. 58 + 59 + ## Development 60 + 61 + ### Project Structure 62 + 63 + ``` 64 + semble-chrome-extension/ 65 + ├── background/ # Background service worker 66 + ├── content/ # Content scripts 67 + ├── icons/ # Extension icons 68 + ├── lib/ # Shared libraries 69 + ├── popup/ # Extension popup UI 70 + │ ├── popup.html 71 + │ ├── popup.js 72 + │ └── styles.css 73 + └── manifest.json # Extension manifest 74 + ``` 75 + 76 + ### Building 77 + 78 + This extension uses vanilla JavaScript and doesn't require a build step. Simply load the unpacked extension in Chrome as described in the Installation section. 79 + 80 + ### Permissions 81 + 82 + The extension requires the following permissions: 83 + - `activeTab`: To capture the current tab's URL 84 + - `storage`: To securely store authentication credentials 85 + - `scripting`: For future content script functionality 86 + - `host_permissions`: To communicate with web APIs 87 + 88 + ## Privacy & Security 89 + 90 + - Your Bluesky credentials are stored locally using Chrome's secure storage API 91 + - The extension only accesses the URL of the current tab when you explicitly click the extension icon 92 + - No data is collected or shared with third parties 93 + 94 + ## License 95 + 96 + This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 97 + 98 + ## Credits 99 + 100 + Semble is being built by [Cosmik Network](https://cosmik.network/). Get involved [here](https://cosmik.network/#connect). 101 + 102 + Learn more about Cosmik Network and Semble [here](https://blog.cosmik.network/). 103 + 104 + ## Contributing 105 + 106 + Contributions are welcome! Please feel free to submit a Pull Request.
+187
background/background.js
··· 1 + /** 2 + * Background Service Worker 3 + * Handles session management and Semble API communication 4 + */ 5 + 6 + // Import Semble API client functions 7 + importScripts('../lib/atproto.js'); 8 + 9 + // Session state 10 + let session = null; 11 + 12 + /** 13 + * Initialize session on extension startup 14 + */ 15 + chrome.runtime.onStartup.addListener(async () => { 16 + await loadSession(); 17 + }); 18 + 19 + /** 20 + * Load session from storage 21 + */ 22 + async function loadSession() { 23 + try { 24 + const result = await chrome.storage.local.get(['session']); 25 + if (result.session) { 26 + session = result.session; 27 + console.log('Session loaded from storage'); 28 + } 29 + } catch (error) { 30 + console.error('Failed to load session:', error); 31 + } 32 + } 33 + 34 + /** 35 + * Save session to storage 36 + */ 37 + async function saveSession(sessionData) { 38 + try { 39 + session = sessionData; 40 + await chrome.storage.local.set({ session: sessionData }); 41 + console.log('Session saved to storage'); 42 + } catch (error) { 43 + console.error('Failed to save session:', error); 44 + throw error; 45 + } 46 + } 47 + 48 + /** 49 + * Clear session from storage 50 + */ 51 + async function clearSession() { 52 + try { 53 + session = null; 54 + await chrome.storage.local.remove('session'); 55 + console.log('Session cleared'); 56 + } catch (error) { 57 + console.error('Failed to clear session:', error); 58 + } 59 + } 60 + 61 + /** 62 + * Authenticate with Semble using Bluesky credentials 63 + */ 64 + async function authenticate(identifier, password) { 65 + try { 66 + const sessionData = await createSession(identifier, password); 67 + await saveSession(sessionData); 68 + return { success: true, session: sessionData }; 69 + } catch (error) { 70 + console.error('Authentication failed:', error); 71 + return { success: false, error: error.message }; 72 + } 73 + } 74 + 75 + /** 76 + * Ensure we have a valid session 77 + * Refreshes token if expired 78 + */ 79 + async function ensureValidSession() { 80 + if (!session) { 81 + await loadSession(); 82 + } 83 + 84 + if (!session) { 85 + throw new Error('Not authenticated. Please log in.'); 86 + } 87 + 88 + // TODO: Add token expiration check and refresh logic using refreshSession() 89 + // For now, we'll assume the token is valid 90 + 91 + return session; 92 + } 93 + 94 + /** 95 + * Get user's collections from Semble 96 + */ 97 + async function getCollections() { 98 + try { 99 + const session = await ensureValidSession(); 100 + const collections = await listCollections(session.accessToken); 101 + return { success: true, collections }; 102 + } catch (error) { 103 + console.error('Failed to fetch collections:', error); 104 + return { success: false, error: error.message }; 105 + } 106 + } 107 + 108 + /** 109 + * Save URL card to Semble collection 110 + */ 111 + async function saveCard(url, metadata, note, collectionId) { 112 + try { 113 + const session = await ensureValidSession(); 114 + 115 + // Semble API handles everything in one call: 116 + // - Fetches URL metadata 117 + // - Creates URL card 118 + // - Creates note card (if note provided) 119 + // - Adds to collection(s) 120 + // - Publishes to ATproto 121 + const collectionIds = collectionId ? [collectionId] : []; 122 + 123 + const result = await addUrlToLibrary( 124 + session.accessToken, 125 + url, 126 + note, 127 + collectionIds 128 + ); 129 + 130 + console.log('Card saved to Semble:', result); 131 + 132 + return { 133 + success: true, 134 + urlCardId: result.urlCardId, 135 + noteCardId: result.noteCardId, 136 + }; 137 + } catch (error) { 138 + console.error('Failed to save card:', error); 139 + return { success: false, error: error.message }; 140 + } 141 + } 142 + 143 + /** 144 + * Handle messages from popup and content scripts 145 + */ 146 + chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 147 + console.log('Background received message:', request); 148 + 149 + switch (request.action) { 150 + case 'authenticate': 151 + authenticate(request.identifier, request.password) 152 + .then(sendResponse); 153 + return true; // Keep channel open for async response 154 + 155 + case 'getSession': 156 + ensureValidSession() 157 + .then(session => sendResponse({ success: true, session })) 158 + .catch(error => sendResponse({ success: false, error: error.message })); 159 + return true; 160 + 161 + case 'clearSession': 162 + clearSession() 163 + .then(() => sendResponse({ success: true })) 164 + .catch(error => sendResponse({ success: false, error: error.message })); 165 + return true; 166 + 167 + case 'getCollections': 168 + getCollections().then(sendResponse); 169 + return true; 170 + 171 + case 'saveCard': 172 + saveCard( 173 + request.url, 174 + request.metadata, 175 + request.note, 176 + request.collectionId 177 + ).then(sendResponse); 178 + return true; 179 + 180 + default: 181 + console.warn('Unknown action:', request.action); 182 + sendResponse({ success: false, error: 'Unknown action' }); 183 + } 184 + }); 185 + 186 + // Load session on script initialization 187 + loadSession();
+77
content/metadata-extractor.js
··· 1 + /** 2 + * Metadata Extractor Content Script 3 + * Extracts page metadata from Open Graph tags, meta tags, and document properties 4 + */ 5 + 6 + /** 7 + * Extract metadata from the current page 8 + * @returns {object} Metadata object 9 + */ 10 + function extractMetadata() { 11 + // Helper function to get meta tag content 12 + const getMeta = (selector) => { 13 + const element = document.querySelector(selector); 14 + return element ? element.getAttribute('content') || '' : ''; 15 + }; 16 + 17 + // Extract title 18 + const title = 19 + getMeta('meta[property="og:title"]') || 20 + getMeta('meta[name="twitter:title"]') || 21 + document.querySelector('title')?.textContent || 22 + document.URL; 23 + 24 + // Extract description 25 + const description = 26 + getMeta('meta[property="og:description"]') || 27 + getMeta('meta[name="twitter:description"]') || 28 + getMeta('meta[name="description"]') || 29 + ''; 30 + 31 + // Extract image 32 + const imageUrl = 33 + getMeta('meta[property="og:image"]') || 34 + getMeta('meta[name="twitter:image"]') || 35 + ''; 36 + 37 + // Extract site name 38 + const hostname = new URL(document.URL).hostname; 39 + const siteName = 40 + getMeta('meta[property="og:site_name"]') || 41 + hostname; 42 + 43 + // Extract author 44 + const author = 45 + getMeta('meta[name="author"]') || 46 + getMeta('meta[property="article:author"]') || 47 + ''; 48 + 49 + // Extract type 50 + const type = 51 + getMeta('meta[property="og:type"]') || 52 + 'website'; 53 + 54 + return { 55 + url: document.URL, 56 + title: title.trim(), 57 + description: description.trim(), 58 + imageUrl: imageUrl.trim(), 59 + siteName: siteName.trim(), 60 + author: author.trim(), 61 + type: type.trim(), 62 + }; 63 + } 64 + 65 + // Listen for messages from popup 66 + chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 67 + if (request.action === 'extractMetadata') { 68 + const metadata = extractMetadata(); 69 + sendResponse(metadata); 70 + } 71 + }); 72 + 73 + // Also send metadata automatically if requested 74 + if (window === window.top) { 75 + // Only run in top frame, not iframes 76 + console.log('Metadata extractor loaded'); 77 + }
icons/icon128.png

This is a binary file and will not be displayed.

icons/icon16.png

This is a binary file and will not be displayed.

icons/icon32.png

This is a binary file and will not be displayed.

icons/icon48.png

This is a binary file and will not be displayed.

icons/icon96.png

This is a binary file and will not be displayed.

+159
lib/atproto.js
··· 1 + /** 2 + * Semble API Client Library 3 + * Handles authentication and communication with Semble backend 4 + */ 5 + 6 + const SEMBLE_API_URL = 'https://api.semble.so'; 7 + // For local development: 'http://127.0.0.1:3000' 8 + 9 + /** 10 + * Login with Bluesky app password to get Semble tokens 11 + * @param {string} identifier - User handle (e.g., user.bsky.social) 12 + * @param {string} password - Bluesky app password 13 + * @returns {Promise<{accessToken: string, refreshToken: string}>} 14 + */ 15 + async function createSession(identifier, password) { 16 + const response = await fetch(`${SEMBLE_API_URL}/api/users/login/app-password`, { 17 + method: 'POST', 18 + headers: { 19 + 'Content-Type': 'application/json', 20 + }, 21 + body: JSON.stringify({ 22 + identifier, 23 + appPassword: password, 24 + }), 25 + }); 26 + 27 + if (!response.ok) { 28 + const error = await response.json().catch(() => ({})); 29 + throw new Error(`Authentication failed: ${error.message || response.statusText}`); 30 + } 31 + 32 + return await response.json(); 33 + } 34 + 35 + /** 36 + * Refresh Semble access token 37 + * @param {string} refreshToken - Refresh token 38 + * @returns {Promise<{accessToken: string, refreshToken: string}>} 39 + */ 40 + async function refreshSession(refreshToken) { 41 + const response = await fetch(`${SEMBLE_API_URL}/api/users/oauth/refresh`, { 42 + method: 'POST', 43 + headers: { 44 + 'Content-Type': 'application/json', 45 + }, 46 + body: JSON.stringify({ 47 + refreshToken, 48 + }), 49 + }); 50 + 51 + if (!response.ok) { 52 + throw new Error('Token refresh failed'); 53 + } 54 + 55 + return await response.json(); 56 + } 57 + 58 + /** 59 + * Get user's Semble collections 60 + * @param {string} accessToken - Semble access token 61 + * @returns {Promise<Array>} 62 + */ 63 + async function listCollections(accessToken) { 64 + const response = await fetch(`${SEMBLE_API_URL}/api/collections`, { 65 + headers: { 66 + 'Authorization': `Bearer ${accessToken}`, 67 + }, 68 + }); 69 + 70 + if (!response.ok) { 71 + const error = await response.json().catch(() => ({})); 72 + throw new Error(`Failed to fetch collections: ${error.message || response.statusText}`); 73 + } 74 + 75 + const data = await response.json(); 76 + return data.collections || []; 77 + } 78 + 79 + /** 80 + * Add URL to library via Semble API (creates card, fetches metadata, adds to collections) 81 + * @param {string} accessToken - Semble access token 82 + * @param {string} url - URL to add 83 + * @param {string} [note] - Optional note 84 + * @param {string[]} [collectionIds] - Optional collection IDs to add card to 85 + * @returns {Promise<{urlCardId: string, noteCardId?: string}>} 86 + */ 87 + async function addUrlToLibrary(accessToken, url, note, collectionIds) { 88 + const payload = { 89 + url, 90 + }; 91 + 92 + if (note && note.trim()) { 93 + payload.note = note.trim(); 94 + } 95 + 96 + if (collectionIds && collectionIds.length > 0) { 97 + payload.collectionIds = collectionIds; 98 + } 99 + 100 + console.log('Adding URL to Semble library:', { 101 + url, 102 + hasNote: !!payload.note, 103 + collectionCount: collectionIds?.length || 0, 104 + }); 105 + 106 + const response = await fetch(`${SEMBLE_API_URL}/api/cards/library/urls`, { 107 + method: 'POST', 108 + headers: { 109 + 'Authorization': `Bearer ${accessToken}`, 110 + 'Content-Type': 'application/json', 111 + }, 112 + body: JSON.stringify(payload), 113 + }); 114 + 115 + if (!response.ok) { 116 + const error = await response.json().catch(() => ({})); 117 + console.error('Failed to add URL to library:', { 118 + status: response.status, 119 + statusText: response.statusText, 120 + error: error, 121 + }); 122 + throw new Error(`Failed to add URL: ${error.message || response.statusText}`); 123 + } 124 + 125 + const result = await response.json(); 126 + console.log('URL added to Semble successfully:', result); 127 + return result; 128 + } 129 + 130 + // Legacy function names for compatibility - no longer used 131 + async function createRecord() { 132 + throw new Error('createRecord is deprecated - use addUrlToLibrary instead'); 133 + } 134 + 135 + async function createUrlCard() { 136 + throw new Error('createUrlCard is deprecated - use addUrlToLibrary instead'); 137 + } 138 + 139 + async function createNoteCard() { 140 + throw new Error('createNoteCard is deprecated - note is now part of addUrlToLibrary'); 141 + } 142 + 143 + async function createCollectionLink() { 144 + throw new Error('createCollectionLink is deprecated - collectionIds is now part of addUrlToLibrary'); 145 + } 146 + 147 + // Export functions for use in other scripts 148 + if (typeof module !== 'undefined' && module.exports) { 149 + module.exports = { 150 + createSession, 151 + refreshSession, 152 + listCollections, 153 + addUrlToLibrary, 154 + createRecord, 155 + createUrlCard, 156 + createNoteCard, 157 + createCollectionLink, 158 + }; 159 + }
+38
manifest.json
··· 1 + { 2 + "manifest_version": 3, 3 + "name": "Semble Quick Capture", 4 + "version": "1.0.0", 5 + "description": "Quickly capture URLs into Semble Collections", 6 + "permissions": [ 7 + "activeTab", 8 + "storage", 9 + "scripting" 10 + ], 11 + "host_permissions": [ 12 + "https://*/*", 13 + "http://*/*" 14 + ], 15 + "action": { 16 + "default_popup": "popup/popup.html", 17 + "default_icon": { 18 + "16": "icons/icon16.png", 19 + "32": "icons/icon32.png", 20 + "48": "icons/icon48.png", 21 + "96": "icons/icon96.png", 22 + "128": "icons/icon128.png" 23 + } 24 + }, 25 + "background": { 26 + "service_worker": "background/background.js" 27 + }, 28 + "icons": { 29 + "16": "icons/icon16.png", 30 + "32": "icons/icon32.png", 31 + "48": "icons/icon48.png", 32 + "96": "icons/icon96.png", 33 + "128": "icons/icon128.png" 34 + }, 35 + "content_security_policy": { 36 + "extension_pages": "script-src 'self'; object-src 'self'" 37 + } 38 + }
+103
popup/popup.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + 4 + <head> 5 + <meta charset="UTF-8"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <title>Semble Quick Capture</title> 8 + <link rel="preconnect" href="https://fonts.googleapis.com"> 9 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 10 + <link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700&display=swap" 11 + rel="stylesheet"> 12 + <link rel="stylesheet" href="styles.css"> 13 + </head> 14 + 15 + <body> 16 + <!-- Auth Screen --> 17 + <div id="loginScreen" class="screen hidden"> 18 + <div class="container"> 19 + <h2 class="title">Semble Quick Capture</h2> 20 + <p class="subtitle">Sign in with your Bluesky account</p> 21 + 22 + <form class="form-stack"> 23 + <div class="form-group"> 24 + <label class="form-label">@Handle</label> 25 + <input type="text" id="loginHandle" placeholder="you.bsky.social" class="input-filled" 26 + autocomplete="username"> 27 + </div> 28 + 29 + <div class="form-group"> 30 + <label class="form-label">App Password</label> 31 + <input type="password" id="loginPassword" placeholder="Your password" class="input-filled" 32 + autocomplete="password"> 33 + <p class="form-hint"> 34 + Generate an <a href="https://bsky.app/settings/app-passwords" target="_blank" rel="noopener noreferrer" 35 + class="form-link">App Password</a>. No account? <a href="https://bsky.app" target="_blank" 36 + rel="noopener noreferrer" class="form-link">Sign up 37 + on Bluesky</a> 38 + </p> 39 + </div> 40 + 41 + <button id="loginButton" type="button" class="btn btn-primary"> 42 + Sign in 43 + </button> 44 + 45 + <div id="loginError" class="alert alert-error hidden"></div> 46 + </form> 47 + </div> 48 + </div> 49 + 50 + <!-- Main Capture Screen --> 51 + <div id="captureScreen" class="screen hidden"> 52 + <!-- Header --> 53 + <div class="header"> 54 + <h1 class="header-title">Semble</h1> 55 + <button id="logoutButton" class="btn-link">Sign Out</button> 56 + </div> 57 + 58 + <!-- Content --> 59 + <div class="container"> 60 + <form class="form-stack"> 61 + <!-- URL Display --> 62 + <div class="form-group"> 63 + <label class="form-label">URL</label> 64 + <div class="url-display" id="currentUrl">Assembling...</div> 65 + </div> 66 + 67 + <!-- Note Input --> 68 + <div class="form-group"> 69 + <label class="form-label">Note (optional)</label> 70 + <textarea id="noteInput" rows="3" placeholder="Add your thoughts..." class="input-filled"></textarea> 71 + </div> 72 + 73 + <!-- Collection Dropdown --> 74 + <div class="form-group"> 75 + <label class="form-label">Collection</label> 76 + <select id="collectionSelect" class="input-filled"> 77 + <option value="">Assembling collections...</option> 78 + </select> 79 + </div> 80 + 81 + <!-- Submit Button --> 82 + <button id="submitButton" type="button" class="btn btn-primary" disabled> 83 + Add to Collection 84 + </button> 85 + 86 + <!-- Status Message --> 87 + <div id="statusMessage" class="alert hidden"></div> 88 + </form> 89 + </div> 90 + </div> 91 + 92 + <!-- Loading Screen --> 93 + <div id="loadingScreen" class="screen"> 94 + <div class="loading-container"> 95 + <div class="spinner"></div> 96 + <p class="loading-text">Assembling...</p> 97 + </div> 98 + </div> 99 + 100 + <script src="popup.js"></script> 101 + </body> 102 + 103 + </html>
+358
popup/popup.js
··· 1 + /** 2 + * Popup Script 3 + * Handles UI logic and communication with background script 4 + */ 5 + 6 + // DOM elements 7 + let loginScreen, captureScreen, loadingScreen; 8 + let loginHandle, loginPassword, loginButton, loginError; 9 + let currentUrl, noteInput, collectionSelect, submitButton, statusMessage, logoutButton; 10 + 11 + // State 12 + let currentTab = null; 13 + let metadata = null; 14 + let collections = []; 15 + 16 + /** 17 + * Initialize popup 18 + */ 19 + document.addEventListener('DOMContentLoaded', async () => { 20 + // Get DOM elements 21 + loginScreen = document.getElementById('loginScreen'); 22 + captureScreen = document.getElementById('captureScreen'); 23 + loadingScreen = document.getElementById('loadingScreen'); 24 + loginHandle = document.getElementById('loginHandle'); 25 + loginPassword = document.getElementById('loginPassword'); 26 + loginButton = document.getElementById('loginButton'); 27 + loginError = document.getElementById('loginError'); 28 + currentUrl = document.getElementById('currentUrl'); 29 + noteInput = document.getElementById('noteInput'); 30 + collectionSelect = document.getElementById('collectionSelect'); 31 + submitButton = document.getElementById('submitButton'); 32 + statusMessage = document.getElementById('statusMessage'); 33 + logoutButton = document.getElementById('logoutButton'); 34 + 35 + // Set up event listeners 36 + loginButton.addEventListener('click', handleLogin); 37 + submitButton.addEventListener('click', handleSubmit); 38 + logoutButton.addEventListener('click', handleLogout); 39 + collectionSelect.addEventListener('change', handleCollectionChange); 40 + 41 + // Enter key for login 42 + loginPassword.addEventListener('keypress', (e) => { 43 + if (e.key === 'Enter') { 44 + handleLogin(); 45 + } 46 + }); 47 + 48 + // Initialize 49 + await initialize(); 50 + }); 51 + 52 + /** 53 + * Initialize the popup 54 + */ 55 + async function initialize() { 56 + showScreen('loading'); 57 + 58 + try { 59 + // Check if user is authenticated 60 + const sessionResult = await sendMessage({ action: 'getSession' }); 61 + 62 + if (sessionResult.success) { 63 + // User is authenticated, show capture screen 64 + await loadCaptureScreen(); 65 + } else { 66 + // User needs to log in 67 + showScreen('login'); 68 + } 69 + } catch (error) { 70 + console.error('Initialization error:', error); 71 + showScreen('login'); 72 + } 73 + } 74 + 75 + /** 76 + * Load the capture screen 77 + */ 78 + async function loadCaptureScreen() { 79 + try { 80 + // Get current tab 81 + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); 82 + currentTab = tab; 83 + 84 + // Display URL 85 + currentUrl.textContent = tab.url; 86 + 87 + // Extract metadata from the page 88 + try { 89 + const results = await chrome.scripting.executeScript({ 90 + target: { tabId: tab.id }, 91 + files: ['content/metadata-extractor.js'] 92 + }); 93 + 94 + // Send message to content script to extract metadata 95 + const metadataResult = await chrome.tabs.sendMessage(tab.id, { action: 'extractMetadata' }); 96 + metadata = metadataResult; 97 + console.log('Metadata extracted:', metadata); 98 + } catch (error) { 99 + console.warn('Failed to extract metadata:', error); 100 + // Use fallback metadata 101 + metadata = { 102 + url: tab.url, 103 + title: tab.title || tab.url, 104 + description: '', 105 + imageUrl: '', 106 + siteName: new URL(tab.url).hostname, 107 + author: '', 108 + type: 'website', 109 + }; 110 + } 111 + 112 + // Load collections 113 + await loadCollections(); 114 + 115 + // Show capture screen 116 + showScreen('capture'); 117 + } catch (error) { 118 + console.error('Failed to load capture screen:', error); 119 + showError('Failed to load page information. Please try again.'); 120 + showScreen('login'); 121 + } 122 + } 123 + 124 + /** 125 + * Load user's collections 126 + */ 127 + async function loadCollections() { 128 + try { 129 + const result = await sendMessage({ action: 'getCollections' }); 130 + 131 + if (result.success) { 132 + collections = result.collections; 133 + populateCollectionDropdown(); 134 + } else { 135 + throw new Error(result.error); 136 + } 137 + } catch (error) { 138 + console.error('Failed to load collections:', error); 139 + showError('Failed to load collections. Please try again.'); 140 + collections = []; 141 + populateCollectionDropdown(); 142 + } 143 + } 144 + 145 + /** 146 + * Populate the collection dropdown 147 + */ 148 + function populateCollectionDropdown() { 149 + collectionSelect.innerHTML = ''; 150 + 151 + if (collections.length === 0) { 152 + const option = document.createElement('option'); 153 + option.value = ''; 154 + option.textContent = 'No collections found'; 155 + collectionSelect.appendChild(option); 156 + submitButton.disabled = true; 157 + } else { 158 + const placeholderOption = document.createElement('option'); 159 + placeholderOption.value = ''; 160 + placeholderOption.textContent = 'Select a collection...'; 161 + collectionSelect.appendChild(placeholderOption); 162 + 163 + collections.forEach(collection => { 164 + const option = document.createElement('option'); 165 + // Semble collections have an 'id' field, not uri/cid 166 + option.value = collection.id; 167 + option.textContent = collection.name || 'Untitled Collection'; 168 + collectionSelect.appendChild(option); 169 + }); 170 + } 171 + } 172 + 173 + /** 174 + * Handle collection selection change 175 + */ 176 + function handleCollectionChange() { 177 + submitButton.disabled = !collectionSelect.value; 178 + } 179 + 180 + /** 181 + * Handle login 182 + */ 183 + async function handleLogin() { 184 + const handle = loginHandle.value.trim(); 185 + const password = loginPassword.value.trim(); 186 + 187 + if (!handle || !password) { 188 + showLoginError('Please enter both handle and password'); 189 + return; 190 + } 191 + 192 + loginButton.disabled = true; 193 + loginButton.textContent = 'Signing in...'; 194 + hideLoginError(); 195 + 196 + try { 197 + const result = await sendMessage({ 198 + action: 'authenticate', 199 + identifier: handle, 200 + password: password, 201 + }); 202 + 203 + if (result.success) { 204 + // Authentication successful 205 + await loadCaptureScreen(); 206 + } else { 207 + showLoginError(result.error || 'Authentication failed'); 208 + loginButton.disabled = false; 209 + loginButton.textContent = 'Sign In'; 210 + } 211 + } catch (error) { 212 + console.error('Login error:', error); 213 + showLoginError('An error occurred. Please try again.'); 214 + loginButton.disabled = false; 215 + loginButton.textContent = 'Sign In'; 216 + } 217 + } 218 + 219 + /** 220 + * Handle logout 221 + */ 222 + async function handleLogout() { 223 + try { 224 + await sendMessage({ action: 'clearSession' }); 225 + showScreen('login'); 226 + loginHandle.value = ''; 227 + loginPassword.value = ''; 228 + } catch (error) { 229 + console.error('Logout error:', error); 230 + } 231 + } 232 + 233 + /** 234 + * Handle form submission 235 + */ 236 + async function handleSubmit() { 237 + const note = noteInput.value.trim(); 238 + const selectedCollectionId = collectionSelect.value; 239 + 240 + if (!selectedCollectionId) { 241 + showStatus('Please select a collection', 'error'); 242 + return; 243 + } 244 + 245 + submitButton.disabled = true; 246 + submitButton.textContent = 'Saving...'; 247 + showStatus('Saving card to Semble...', 'loading'); 248 + 249 + try { 250 + const result = await sendMessage({ 251 + action: 'saveCard', 252 + url: currentTab.url, 253 + metadata: metadata, 254 + note: note || undefined, 255 + collectionId: selectedCollectionId, 256 + }); 257 + 258 + if (result.success) { 259 + showStatus('Card saved successfully!', 'success'); 260 + // Clear form 261 + noteInput.value = ''; 262 + collectionSelect.value = ''; 263 + submitButton.disabled = true; 264 + 265 + // Close popup after 1.5 seconds 266 + setTimeout(() => { 267 + window.close(); 268 + }, 1500); 269 + } else { 270 + showStatus(result.error || 'Failed to save card', 'error'); 271 + submitButton.disabled = false; 272 + submitButton.textContent = 'Add to Collection'; 273 + } 274 + } catch (error) { 275 + console.error('Submit error:', error); 276 + showStatus('An error occurred. Please try again.', 'error'); 277 + submitButton.disabled = false; 278 + submitButton.textContent = 'Add to Collection'; 279 + } 280 + } 281 + 282 + /** 283 + * Show a screen 284 + */ 285 + function showScreen(screen) { 286 + loginScreen.classList.add('hidden'); 287 + captureScreen.classList.add('hidden'); 288 + loadingScreen.classList.add('hidden'); 289 + 290 + switch (screen) { 291 + case 'login': 292 + loginScreen.classList.remove('hidden'); 293 + break; 294 + case 'capture': 295 + captureScreen.classList.remove('hidden'); 296 + break; 297 + case 'loading': 298 + loadingScreen.classList.remove('hidden'); 299 + break; 300 + } 301 + } 302 + 303 + /** 304 + * Show status message 305 + */ 306 + function showStatus(message, type) { 307 + statusMessage.textContent = message; 308 + statusMessage.classList.remove('hidden', 'alert-loading', 'alert-success', 'alert-error'); 309 + 310 + switch (type) { 311 + case 'loading': 312 + statusMessage.classList.add('alert-loading'); 313 + break; 314 + case 'success': 315 + statusMessage.classList.add('alert-success'); 316 + break; 317 + case 'error': 318 + statusMessage.classList.add('alert-error'); 319 + break; 320 + } 321 + } 322 + 323 + /** 324 + * Show error message 325 + */ 326 + function showError(message) { 327 + showStatus(message, 'error'); 328 + } 329 + 330 + /** 331 + * Show login error 332 + */ 333 + function showLoginError(message) { 334 + loginError.textContent = message; 335 + loginError.classList.remove('hidden'); 336 + } 337 + 338 + /** 339 + * Hide login error 340 + */ 341 + function hideLoginError() { 342 + loginError.classList.add('hidden'); 343 + } 344 + 345 + /** 346 + * Send message to background script 347 + */ 348 + function sendMessage(message) { 349 + return new Promise((resolve, reject) => { 350 + chrome.runtime.sendMessage(message, (response) => { 351 + if (chrome.runtime.lastError) { 352 + reject(chrome.runtime.lastError); 353 + } else { 354 + resolve(response); 355 + } 356 + }); 357 + }); 358 + }
+392
popup/styles.css
··· 1 + /** 2 + * Semble Quick Capture - Styles 3 + * Matches Semble's design system using Mantine-inspired styling 4 + * 5 + * @format 6 + */ 7 + 8 + /* ========== CSS Variables ========== */ 9 + :root { 10 + /* Semble Colors - Light Mode */ 11 + --tangerine-6: #ff6400; 12 + --tangerine-7: #e45800; 13 + --tangerine-8: #cb4d00; 14 + 15 + --stone-0: #fafaf9; 16 + --stone-1: #f5f5f4; 17 + --stone-2: #e7e5e4; 18 + --stone-3: #d6d3d1; 19 + --stone-4: #a8a29e; 20 + --stone-5: #78716c; 21 + --stone-6: #57534e; 22 + --stone-7: #44403c; 23 + --stone-8: #292524; 24 + --stone-9: #0c0a09; 25 + 26 + /* Semantic Colors - Light Mode */ 27 + --bg-default: #ffffff; 28 + --bg-subtle: var(--stone-0); 29 + --bg-filled: var(--stone-1); 30 + --text-primary: var(--stone-9); 31 + --text-secondary: var(--stone-6); 32 + --text-dimmed: var(--stone-5); 33 + --border-default: var(--stone-2); 34 + --border-subtle: var(--stone-1); 35 + 36 + --primary-color: var(--tangerine-6); 37 + --primary-hover: var(--tangerine-7); 38 + --primary-active: var(--tangerine-8); 39 + 40 + /* Filled button (dark) */ 41 + --filled-bg: var(--stone-8); 42 + --filled-hover: var(--stone-7); 43 + --filled-active: var(--stone-9); 44 + 45 + /* Error colors */ 46 + --error-bg: #fef2f2; 47 + --error-border: #fca5a5; 48 + --error-text: #991b1b; 49 + 50 + /* Success colors */ 51 + --success-bg: #f0fdf4; 52 + --success-border: #86efac; 53 + --success-text: #166534; 54 + 55 + /* Spacing */ 56 + --spacing-xs: 0.625rem; 57 + --spacing-sm: 0.75rem; 58 + --spacing-md: 1rem; 59 + --spacing-lg: 1.25rem; 60 + --spacing-xl: 1.5rem; 61 + 62 + /* Radius */ 63 + --radius-sm: 0.25rem; 64 + --radius-md: 0.5rem; 65 + --radius-lg: 0.75rem; 66 + --radius-xl: 1rem; 67 + 68 + /* Font sizes */ 69 + --font-size-xs: 0.75rem; 70 + --font-size-sm: 0.875rem; 71 + --font-size-md: 1rem; 72 + --font-size-lg: 1.125rem; 73 + --font-size-xl: 1.25rem; 74 + } 75 + 76 + /* Dark Mode */ 77 + @media (prefers-color-scheme: dark) { 78 + :root { 79 + /* Background colors */ 80 + --bg-default: var(--stone-9); 81 + --bg-subtle: var(--stone-8); 82 + --bg-filled: var(--stone-7); 83 + 84 + /* Text colors */ 85 + --text-primary: var(--stone-0); 86 + --text-secondary: var(--stone-3); 87 + --text-dimmed: var(--stone-4); 88 + 89 + /* Border colors */ 90 + --border-default: var(--stone-7); 91 + --border-subtle: var(--stone-8); 92 + 93 + /* Filled button stays dark in dark mode */ 94 + --filled-bg: var(--stone-1); 95 + --filled-hover: var(--stone-2); 96 + --filled-active: var(--stone-0); 97 + 98 + /* Error colors - dark mode */ 99 + --error-bg: #450a0a; 100 + --error-border: #7f1d1d; 101 + --error-text: #fca5a5; 102 + 103 + /* Success colors - dark mode */ 104 + --success-bg: #052e16; 105 + --success-border: #166534; 106 + --success-text: #86efac; 107 + } 108 + } 109 + 110 + /* ========== Base Styles ========== */ 111 + * { 112 + box-sizing: border-box; 113 + } 114 + 115 + body { 116 + width: 360px; 117 + min-height: 300px; 118 + max-height: 600px; 119 + margin: 0; 120 + padding: 0; 121 + font-family: 'Hanken Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', 122 + Roboto, 'Helvetica Neue', Arial, sans-serif; 123 + font-size: var(--font-size-md); 124 + color: var(--text-primary); 125 + background-color: var(--bg-default); 126 + overflow-y: auto; 127 + } 128 + 129 + :focus { 130 + outline: 2px solid var(--primary-color); 131 + } 132 + 133 + /* ========== Layout Components ========== */ 134 + .screen { 135 + min-height: 300px; 136 + } 137 + 138 + .container { 139 + padding: var(--spacing-xl); 140 + } 141 + 142 + .header { 143 + display: flex; 144 + justify-content: space-between; 145 + align-items: center; 146 + padding: var(--spacing-md) var(--spacing-lg); 147 + background-color: var(--bg-default); 148 + border-bottom: 1px solid var(--border-default); 149 + } 150 + 151 + .header-title { 152 + font-size: var(--font-size-lg); 153 + font-weight: 700; 154 + margin: 0; 155 + color: var(--text-primary); 156 + } 157 + 158 + /* ========== Typography ========== */ 159 + .title { 160 + font-size: var(--font-size-xl); 161 + font-weight: 700; 162 + margin: 0 0 var(--spacing-sm) 0; 163 + color: var(--text-primary); 164 + } 165 + 166 + .subtitle { 167 + font-size: var(--font-size-sm); 168 + color: var(--text-secondary); 169 + margin: 0 0 var(--spacing-lg) 0; 170 + } 171 + 172 + .loading-text { 173 + font-size: var(--font-size-sm); 174 + color: var(--text-secondary); 175 + margin-top: var(--spacing-md); 176 + } 177 + 178 + /* ========== Form Components ========== */ 179 + .form-stack { 180 + display: flex; 181 + flex-direction: column; 182 + gap: var(--spacing-lg); 183 + } 184 + 185 + .form-group { 186 + display: flex; 187 + flex-direction: column; 188 + gap: var(--spacing-xs); 189 + } 190 + 191 + .form-label { 192 + font-size: var(--font-size-sm); 193 + font-weight: 600; 194 + color: var(--text-primary); 195 + } 196 + 197 + .form-hint { 198 + font-size: var(--font-size-sm); 199 + color: var(--text-dimmed); 200 + margin: 0; 201 + line-height: 1.4; 202 + } 203 + 204 + .form-link { 205 + color: var(--primary-color); 206 + text-decoration: none; 207 + font-weight: 500; 208 + transition: color 150ms ease; 209 + } 210 + 211 + .form-link:hover { 212 + color: var(--primary-hover); 213 + text-decoration: underline; 214 + } 215 + 216 + /* Filled Input Variant (Mantine style) */ 217 + .input-filled { 218 + width: 100%; 219 + padding: calc(var(--spacing-md) - 2px) var(--spacing-md); 220 + font-size: var(--font-size-md); 221 + font-family: inherit; 222 + color: var(--text-primary); 223 + background-color: var(--bg-filled); 224 + border: 2px solid transparent; 225 + border-radius: var(--radius-md); 226 + transition: all 150ms ease; 227 + } 228 + 229 + .input-filled::placeholder { 230 + color: var(--text-dimmed); 231 + } 232 + 233 + .input-filled:hover { 234 + background-color: var(--bg-subtle); 235 + } 236 + 237 + /* .input-filled:focus { 238 + outline: 1px solid; 239 + out- 240 + background-color: var(--bg-default); 241 + border-color: var(--primary-color); 242 + } */ 243 + 244 + .input-filled:disabled { 245 + opacity: 0.6; 246 + cursor: not-allowed; 247 + } 248 + 249 + /* Textarea specific */ 250 + textarea.input-filled { 251 + resize: vertical; 252 + min-height: 80px; 253 + font-family: inherit; 254 + } 255 + 256 + /* Select specific */ 257 + select.input-filled { 258 + cursor: pointer; 259 + appearance: none; 260 + background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4 6l4 4 4-4" stroke="%2378716c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>'); 261 + background-repeat: no-repeat; 262 + background-position: right var(--spacing-md) center; 263 + padding-right: calc(var(--spacing-md) * 2.5); 264 + } 265 + 266 + @media (prefers-color-scheme: dark) { 267 + select.input-filled { 268 + background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4 6l4 4 4-4" stroke="%23d6d3d1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>'); 269 + } 270 + } 271 + 272 + /* ========== Buttons ========== */ 273 + .btn { 274 + width: 100%; 275 + padding: var(--spacing-md) var(--spacing-lg); 276 + font-size: var(--font-size-md); 277 + font-weight: 600; 278 + font-family: inherit; 279 + border: none; 280 + border-radius: var(--radius-xl); 281 + cursor: pointer; 282 + transition: all 150ms ease; 283 + display: flex; 284 + align-items: center; 285 + justify-content: center; 286 + gap: var(--spacing-sm); 287 + } 288 + 289 + .btn-primary { 290 + background-color: var(--primary-color); 291 + color: white; 292 + } 293 + 294 + .btn-primary:hover:not(:disabled) { 295 + background-color: var(--primary-hover); 296 + } 297 + 298 + .btn-primary:active:not(:disabled) { 299 + background-color: var(--primary-active); 300 + transform: translateY(1px); 301 + } 302 + 303 + .btn-primary:disabled { 304 + opacity: 0.4; 305 + cursor: not-allowed; 306 + } 307 + 308 + .btn-link { 309 + background: none; 310 + border: none; 311 + padding: var(--spacing-xs) var(--spacing-sm); 312 + font-size: var(--font-size-sm); 313 + font-weight: 500; 314 + font-family: inherit; 315 + color: var(--text-secondary); 316 + cursor: pointer; 317 + border-radius: var(--radius-md); 318 + transition: all 150ms ease; 319 + } 320 + 321 + .btn-link:hover { 322 + color: var(--text-primary); 323 + background-color: var(--bg-subtle); 324 + } 325 + 326 + /* ========== URL Display ========== */ 327 + .url-display { 328 + padding: var(--spacing-md); 329 + font-size: var(--font-size-sm); 330 + color: var(--text-secondary); 331 + background-color: var(--bg-filled); 332 + border: 1px solid var(--border-default); 333 + border-radius: var(--radius-md); 334 + word-break: break-all; 335 + max-height: 100px; 336 + overflow-y: auto; 337 + } 338 + 339 + /* ========== Alerts ========== */ 340 + .alert { 341 + padding: var(--spacing-md); 342 + border-radius: var(--radius-md); 343 + font-size: var(--font-size-sm); 344 + border: 1px solid; 345 + } 346 + 347 + .alert-error { 348 + background-color: var(--error-bg); 349 + border-color: var(--error-border); 350 + color: var(--error-text); 351 + } 352 + 353 + .alert-success { 354 + background-color: var(--success-bg); 355 + border-color: var(--success-border); 356 + color: var(--success-text); 357 + } 358 + 359 + .alert-loading { 360 + background-color: var(--bg-filled); 361 + border-color: var(--border-default); 362 + color: var(--text-secondary); 363 + } 364 + 365 + /* ========== Loading Spinner ========== */ 366 + .loading-container { 367 + display: flex; 368 + flex-direction: column; 369 + align-items: center; 370 + justify-content: center; 371 + min-height: 300px; 372 + } 373 + 374 + .spinner { 375 + width: 32px; 376 + height: 32px; 377 + border: 3px solid var(--border-default); 378 + border-top-color: var(--primary-color); 379 + border-radius: 50%; 380 + animation: spin 0.8s linear infinite; 381 + } 382 + 383 + @keyframes spin { 384 + to { 385 + transform: rotate(360deg); 386 + } 387 + } 388 + 389 + /* ========== Utility Classes ========== */ 390 + .hidden { 391 + display: none !important; 392 + }