Firefox WebExtension that lets you share the current tab to frontpage.fyi with minimal effort.

First workable version

+36
.gitignore
··· 1 + # Generated by Rust tooling 2 + /target/ 3 + **/target/ 4 + 5 + # Cargo & build artifacts 6 + /.cargo/ 7 + **/.cargo/ 8 + 9 + # IDEs & editors 10 + /.idea/ 11 + .vscode/ 12 + *.iml 13 + 14 + # OS junk 15 + .DS_Store 16 + Thumbs.db 17 + 18 + # Coverage & benchmarks 19 + coverage/ 20 + .profraw 21 + 22 + # Notebooks & reports 23 + notebooks/.ipynb_checkpoints/ 24 + 25 + # Logs & temp 26 + *.log 27 + *.tmp 28 + 29 + # IA 30 + Agents.md 31 + AGENT.md 32 + 33 + # Tests 34 + *.fq 35 + out/ 36 + /wasm-playground/.vite/
+24
Dockerfile
··· 1 + FROM fedora:40 2 + 3 + # Install system packages required for Rust builds and Node-based Codex CLI 4 + RUN dnf install -y \ 5 + curl \ 6 + ca-certificates \ 7 + make \ 8 + gcc \ 9 + pkg-config \ 10 + openssl-devel \ 11 + git \ 12 + nodejs \ 13 + npm \ 14 + && dnf clean all 15 + 16 + # Install OpenAI Codex CLI from npm registry 17 + RUN npm install -g @openai/codex 18 + 19 + # Create a non-root user to run the CLI by default 20 + RUN useradd --create-home --shell /bin/bash app 21 + USER app 22 + WORKDIR /home/app 23 + 24 + CMD ["codex", "--help"]
+63
README.md
··· 1 + ## frontpage.fyi Firefox extension 2 + 3 + This repository provides a Firefox WebExtension that lets you share the current tab to [frontpage.fyi](https://frontpage.fyi) with minimal effort. 4 + Links are submitted by creating `fyi.unravel.frontpage.post` records on your ATProto account, the same mechanism the official Frontpage site uses. 5 + 6 + > ℹ️ The Frontpage source code lives at <https://tangled.org/did:plc:klmr76mpewpv7rtm3xgpzd7x/frontpage>. 7 + 8 + ### Features 9 + 10 + - Pop-up form that auto-fills the active tab’s title and URL. 11 + - Title length indicator (120 characters, matching the Frontpage UI). 12 + - Background service worker handles ATProto login, token refresh, and record creation. 13 + - Options page for storing your handle, app password, and optional PDS override. 14 + - Convenience links to open frontpage.fyi or the options page from the pop-up. 15 + 16 + ### Repository layout 17 + 18 + ``` 19 + extension/ 20 + ├── background.js # Service worker for auth + ATProto requests 21 + ├── manifest.json # Manifest V3 definition 22 + ├── options.html/js # Credential management UI 23 + ├── popup.html/js # Submission UI 24 + └── styles.css # Shared styling for popup and options 25 + ``` 26 + 27 + ### Prerequisites 28 + 29 + - An ATProto account that Frontpage can read. 30 + - An app password for that account (create one at <https://bsky.app/settings/app-passwords> or via your own PDS). 31 + 32 + ### Load the add-on in Firefox 33 + 34 + 1. Open `about:debugging#/runtime/this-firefox`. 35 + 2. Click **Load Temporary Add-on…** and choose `manifest.json` inside the `extension/` directory. 36 + 3. Pin the “Frontpage” toolbar button if you want quick access. 37 + 38 + ### Configure credentials 39 + 40 + 1. Open the add-on pop-up and press the gear icon (or use `about:addons` → **Preferences**). 41 + 2. Enter your handle and app password. Supply a PDS URL only if you run a custom server. 42 + 3. Click **Save credentials**. A success message confirms that the session tokens are stored locally. 43 + 4. Use **Log out** at any time to remove stored tokens (you can also revoke the app password server-side). 44 + 45 + ### Submit a link 46 + 47 + 1. Browse to the page you want to share. 48 + 2. Open the Frontpage pop-up; the title and URL are pre-filled. 49 + 3. Adjust the text if necessary and click **Post to Frontpage**. 50 + 4. On success, the pop-up reports the record URI returned by `com.atproto.repo.createRecord`. 51 + 52 + ### Implementation notes 53 + 54 + - The background worker discovers the user’s PDS by resolving the handle (`com.atproto.identity.resolveHandle` + PLC lookup). 55 + - Sessions are refreshed automatically via `com.atproto.server.refreshSession` when the access JWT expires. 56 + - All data stays in `browser.storage.local`; nothing is transmitted to third-party services beyond the ATProto endpoints. 57 + - Maximum lengths follow the current Frontpage limits (120 characters for the title, 2048 for URLs). 58 + 59 + ### Development tips 60 + 61 + - Inspect background/service-worker logs from `about:debugging` → **Inspect**. 62 + - The UI scripts (`popup.js` and `options.js`) log to the DevTools console attached to their respective documents. 63 + - When packaging for distribution, zip the contents of the `extension/` directory.
+203
extension/background.js
··· 1 + const STORAGE_KEY = "frontpageAuth"; 2 + const FRONTPAGE_COLLECTION = "fyi.unravel.frontpage.post"; 3 + const RECORD_TYPE = "fyi.unravel.frontpage.post"; 4 + const DEFAULT_MAX_TITLE = 120; 5 + const DEFAULT_MAX_URL = 2048; 6 + 7 + async function getStoredAuth() { 8 + const stored = await browser.storage.local.get(STORAGE_KEY); 9 + return stored[STORAGE_KEY] ?? null; 10 + } 11 + 12 + async function setStoredAuth(auth) { 13 + await browser.storage.local.set({ [STORAGE_KEY]: auth }); 14 + } 15 + 16 + async function clearStoredAuth() { 17 + await browser.storage.local.remove(STORAGE_KEY); 18 + } 19 + 20 + async function resolveHandle(handle) { 21 + const endpoint = "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle"; 22 + const url = `${endpoint}?handle=${encodeURIComponent(handle)}`; 23 + const res = await fetch(url); 24 + if (!res.ok) { 25 + throw new Error(`Handle resolution failed (${res.status})`); 26 + } 27 + const data = await res.json(); 28 + if (!data.did) { 29 + throw new Error("Handle resolution response missing DID"); 30 + } 31 + return data.did; 32 + } 33 + 34 + async function lookupPds(did) { 35 + const url = `https://plc.directory/${encodeURIComponent(did)}`; 36 + const res = await fetch(url); 37 + if (!res.ok) { 38 + throw new Error(`PLC lookup failed (${res.status})`); 39 + } 40 + const doc = await res.json(); 41 + const services = Array.isArray(doc.service) ? doc.service : []; 42 + const pds = services.find((s) => s.type === "AtprotoPersonalDataServer")?.serviceEndpoint; 43 + if (!pds) { 44 + throw new Error("Unable to determine personal data server"); 45 + } 46 + return pds.replace(/\/+$/, ""); 47 + } 48 + 49 + async function createSession({ identifier, password, pds }) { 50 + const res = await fetch(`${pds}/xrpc/com.atproto.server.createSession`, { 51 + method: "POST", 52 + headers: { "Content-Type": "application/json" }, 53 + body: JSON.stringify({ identifier, password }) 54 + }); 55 + if (!res.ok) { 56 + const errorText = await res.text(); 57 + throw new Error(`Login failed (${res.status}): ${errorText || res.statusText}`); 58 + } 59 + return res.json(); 60 + } 61 + 62 + async function refreshSession(auth) { 63 + const res = await fetch(`${auth.pds}/xrpc/com.atproto.server.refreshSession`, { 64 + method: "POST", 65 + headers: { 66 + Authorization: `Bearer ${auth.refreshJwt}` 67 + } 68 + }); 69 + if (!res.ok) { 70 + throw new Error(`Session refresh failed (${res.status})`); 71 + } 72 + const data = await res.json(); 73 + const updated = { 74 + ...auth, 75 + accessJwt: data.accessJwt, 76 + refreshJwt: data.refreshJwt, 77 + did: data.did ?? auth.did, 78 + handle: data.handle ?? auth.handle 79 + }; 80 + await setStoredAuth(updated); 81 + return updated; 82 + } 83 + 84 + function decodeJwt(token) { 85 + try { 86 + const payloadPart = token.split(".")[1]; 87 + const normalized = payloadPart.replace(/-/g, "+").replace(/_/g, "/"); 88 + const padded = 89 + normalized + "=".repeat((4 - (normalized.length % 4 || 4)) % 4); 90 + const json = atob(padded); 91 + return JSON.parse(json); 92 + } catch { 93 + return null; 94 + } 95 + } 96 + 97 + function isExpired(token, skewSeconds = 30) { 98 + const payload = decodeJwt(token); 99 + if (!payload?.exp) return false; 100 + const expiresAt = payload.exp * 1000; 101 + return Date.now() + skewSeconds * 1000 >= expiresAt; 102 + } 103 + 104 + async function ensureSession() { 105 + let auth = await getStoredAuth(); 106 + if (!auth) { 107 + throw new Error("Not authenticated. Set up your credentials in the add-on options."); 108 + } 109 + if (isExpired(auth.accessJwt)) { 110 + auth = await refreshSession(auth); 111 + } 112 + return auth; 113 + } 114 + 115 + async function createFrontpageRecord({ title, url }, authOverride) { 116 + const trimmedTitle = (title ?? "").trim().slice(0, DEFAULT_MAX_TITLE); 117 + const trimmedUrl = (url ?? "").trim().slice(0, DEFAULT_MAX_URL); 118 + if (!trimmedTitle) { 119 + throw new Error("Title is required."); 120 + } 121 + try { 122 + new URL(trimmedUrl); 123 + } catch { 124 + throw new Error("URL is invalid."); 125 + } 126 + 127 + const auth = authOverride ?? (await ensureSession()); 128 + const body = { 129 + repo: auth.did, 130 + collection: FRONTPAGE_COLLECTION, 131 + record: { 132 + $type: RECORD_TYPE, 133 + title: trimmedTitle, 134 + url: trimmedUrl, 135 + createdAt: new Date().toISOString() 136 + } 137 + }; 138 + 139 + const res = await fetch(`${auth.pds}/xrpc/com.atproto.repo.createRecord`, { 140 + method: "POST", 141 + headers: { 142 + Authorization: `Bearer ${auth.accessJwt}`, 143 + "Content-Type": "application/json" 144 + }, 145 + body: JSON.stringify(body) 146 + }); 147 + 148 + if (res.status === 401 && !authOverride) { 149 + // Attempt one refresh 150 + const refreshed = await refreshSession(auth); 151 + return createFrontpageRecord({ title: trimmedTitle, url: trimmedUrl }, refreshed); 152 + } 153 + 154 + if (!res.ok) { 155 + const errorText = await res.text(); 156 + throw new Error(`Post failed (${res.status}): ${errorText || res.statusText}`); 157 + } 158 + 159 + return res.json(); 160 + } 161 + 162 + browser.runtime.onMessage.addListener((message) => { 163 + switch (message?.type) { 164 + case "frontpage-submit": 165 + return createFrontpageRecord(message.payload).then( 166 + (result) => ({ ok: true, result }), 167 + (error) => ({ ok: false, error: error.message }) 168 + ); 169 + case "frontpage-login": 170 + return handleLogin(message.payload).then( 171 + () => ({ ok: true }), 172 + (error) => ({ ok: false, error: error.message }) 173 + ); 174 + case "frontpage-logout": 175 + return clearStoredAuth().then(() => ({ ok: true })); 176 + case "frontpage-get-auth": 177 + return getStoredAuth().then((auth) => ({ ok: true, auth: auth ?? null })); 178 + default: 179 + return false; 180 + } 181 + }); 182 + 183 + async function handleLogin(payload) { 184 + if (!payload?.handle || !payload?.password) { 185 + throw new Error("Handle and app password are required."); 186 + } 187 + const handle = payload.handle.trim().toLowerCase(); 188 + const password = payload.password.trim(); 189 + const pds = 190 + payload.pds?.trim().replace(/\/+$/, "") || 191 + (await lookupPds(await resolveHandle(handle))); 192 + const session = await createSession({ identifier: handle, password, pds }); 193 + const stored = { 194 + handle: session.handle ?? handle, 195 + did: session.did, 196 + accessJwt: session.accessJwt, 197 + refreshJwt: session.refreshJwt, 198 + email: session.email ?? null, 199 + pds, 200 + createdAt: new Date().toISOString() 201 + }; 202 + await setStoredAuth(stored); 203 + }
+32
extension/manifest.json
··· 1 + { 2 + "manifest_version": 3, 3 + "name": "Frontpage Submitter", 4 + "description": "Quickly share the current tab to frontpage.fyi via ATProto.", 5 + "version": "0.1.0", 6 + "permissions": [ 7 + "storage", 8 + "tabs", 9 + "activeTab" 10 + ], 11 + "host_permissions": [ 12 + "https://*/*" 13 + ], 14 + "action": { 15 + "default_popup": "popup.html" 16 + }, 17 + "options_ui": { 18 + "page": "options.html", 19 + "open_in_tab": true 20 + }, 21 + "background": { 22 + "scripts": [ 23 + "background.js" 24 + ], 25 + "persistent": false 26 + }, 27 + "browser_specific_settings": { 28 + "gecko": { 29 + "id": "frontpage-submitter@example.com" 30 + } 31 + } 32 + }
+52
extension/options.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <title>Frontpage Submitter Options</title> 6 + <link rel="stylesheet" href="styles.css" /> 7 + </head> 8 + <body> 9 + <main class="options"> 10 + <h1>Frontpage Submitter</h1> 11 + <section> 12 + <h2>Account</h2> 13 + <p class="help"> 14 + Sign in with your Frontpage / ATProto handle and an app password. You can manage app 15 + passwords in the 16 + <a href="https://bsky.app/settings/app-passwords" target="_blank" rel="noreferrer">Bluesky settings</a> 17 + or on your self-hosted PDS. 18 + </p> 19 + <form id="login-form"> 20 + <label for="handle">Handle</label> 21 + <input id="handle" name="handle" type="text" placeholder="you.example.com" required /> 22 + 23 + <label for="password">App password</label> 24 + <input id="password" name="password" type="password" autocomplete="current-password" required /> 25 + 26 + <label for="pds">PDS URL (optional override)</label> 27 + <input 28 + id="pds" 29 + name="pds" 30 + type="url" 31 + placeholder="https://pds.example.com" 32 + autocomplete="url" 33 + /> 34 + 35 + <button type="submit" id="login-btn">Save credentials</button> 36 + </form> 37 + <button type="button" id="logout-btn" class="secondary">Log out</button> 38 + <p id="auth-status" role="status" aria-live="polite"></p> 39 + </section> 40 + 41 + <section> 42 + <h2>How it works</h2> 43 + <ul class="tips"> 44 + <li>The add-on stores your tokens locally using Firefox storage.</li> 45 + <li>Links are published as <code>fyi.unravel.frontpage.post</code> records on your ATProto account.</li> 46 + <li>Your PDS is discovered from your handle automatically unless you provide a custom URL.</li> 47 + </ul> 48 + </section> 49 + </main> 50 + <script type="module" src="options.js"></script> 51 + </body> 52 + </html>
+79
extension/options.js
··· 1 + const api = globalThis.browser ?? globalThis.chrome; 2 + 3 + const form = document.getElementById("login-form"); 4 + const handleInput = document.getElementById("handle"); 5 + const passwordInput = document.getElementById("password"); 6 + const pdsInput = document.getElementById("pds"); 7 + const loginBtn = document.getElementById("login-btn"); 8 + const logoutBtn = document.getElementById("logout-btn"); 9 + const statusEl = document.getElementById("auth-status"); 10 + 11 + function setStatus(message, isError = false) { 12 + statusEl.textContent = message; 13 + statusEl.className = isError ? "status error" : "status success"; 14 + } 15 + 16 + async function refreshAuthStatus() { 17 + try { 18 + const response = await api.runtime.sendMessage({ type: "frontpage-get-auth" }); 19 + if (!response?.auth) { 20 + setStatus("Not connected. Save your handle and app password to enable posting."); 21 + logoutBtn.disabled = true; 22 + return; 23 + } 24 + const auth = response.auth; 25 + handleInput.value = auth.handle ?? ""; 26 + pdsInput.value = auth.pds ?? ""; 27 + passwordInput.value = ""; 28 + logoutBtn.disabled = false; 29 + setStatus(`Connected as ${auth.handle} (${auth.did}).`); 30 + } catch (error) { 31 + console.error("Unable to read auth state", error); 32 + setStatus("Could not read authentication state.", true); 33 + } 34 + } 35 + 36 + form.addEventListener("submit", async (event) => { 37 + event.preventDefault(); 38 + setStatus("Signing in…"); 39 + loginBtn.disabled = true; 40 + try { 41 + const payload = { 42 + handle: handleInput.value, 43 + password: passwordInput.value, 44 + pds: pdsInput.value 45 + }; 46 + const response = await api.runtime.sendMessage({ 47 + type: "frontpage-login", 48 + payload 49 + }); 50 + if (!response?.ok) { 51 + throw new Error(response?.error ?? "Unknown error"); 52 + } 53 + passwordInput.value = ""; 54 + setStatus("Credentials saved."); 55 + await refreshAuthStatus(); 56 + } catch (error) { 57 + console.error("Login failed", error); 58 + setStatus(error.message, true); 59 + } finally { 60 + loginBtn.disabled = false; 61 + } 62 + }); 63 + 64 + logoutBtn.addEventListener("click", async () => { 65 + logoutBtn.disabled = true; 66 + try { 67 + await api.runtime.sendMessage({ type: "frontpage-logout" }); 68 + passwordInput.value = ""; 69 + pdsInput.value = ""; 70 + setStatus("Logged out."); 71 + } catch (error) { 72 + console.error("Logout failed", error); 73 + setStatus(error.message, true); 74 + } finally { 75 + await refreshAuthStatus(); 76 + } 77 + }); 78 + 79 + refreshAuthStatus();
+35
extension/popup.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <title>Frontpage Submitter</title> 6 + <link rel="stylesheet" href="styles.css" /> 7 + </head> 8 + <body> 9 + <main class="popup"> 10 + <header> 11 + <h1>Frontpage</h1> 12 + <button id="open-options" type="button" title="Configure account">⚙</button> 13 + </header> 14 + <form id="submit-form"> 15 + <label for="title"> 16 + Title 17 + <span id="title-count" class="counter">0/120</span> 18 + </label> 19 + <input id="title" name="title" type="text" maxlength="120" required /> 20 + 21 + <label for="url"> 22 + URL 23 + </label> 24 + <input id="url" name="url" type="url" required /> 25 + 26 + <button id="submit-btn" type="submit">Post to Frontpage</button> 27 + </form> 28 + <p id="status" role="status" aria-live="polite"></p> 29 + <footer> 30 + <a id="open-frontpage" href="https://frontpage.fyi" target="_blank" rel="noreferrer">Open frontpage.fyi</a> 31 + </footer> 32 + </main> 33 + <script type="module" src="popup.js"></script> 34 + </body> 35 + </html>
+104
extension/popup.js
··· 1 + const api = globalThis.browser ?? globalThis.chrome; 2 + const MAX_TITLE = 120; 3 + 4 + const form = document.getElementById("submit-form"); 5 + const titleInput = document.getElementById("title"); 6 + const urlInput = document.getElementById("url"); 7 + const statusEl = document.getElementById("status"); 8 + const titleCountEl = document.getElementById("title-count"); 9 + const submitBtn = document.getElementById("submit-btn"); 10 + const openOptionsBtn = document.getElementById("open-options"); 11 + const openFrontpageLink = document.getElementById("open-frontpage"); 12 + 13 + function showStatus(message, isError = false) { 14 + statusEl.textContent = message; 15 + statusEl.className = isError ? "status error" : "status success"; 16 + } 17 + 18 + function updateTitleCounter() { 19 + const value = titleInput.value ?? ""; 20 + titleCountEl.textContent = `${value.length}/${MAX_TITLE}`; 21 + if (value.length > MAX_TITLE) { 22 + titleCountEl.classList.add("over-limit"); 23 + } else { 24 + titleCountEl.classList.remove("over-limit"); 25 + } 26 + } 27 + 28 + async function populateFromTab() { 29 + try { 30 + const tabs = await api.tabs.query({ active: true, currentWindow: true }); 31 + const tab = tabs?.[0]; 32 + if (!tab) return; 33 + if (tab.title) { 34 + titleInput.value = tab.title.trim().slice(0, MAX_TITLE); 35 + } 36 + if (tab.url && /^https?:/i.test(tab.url)) { 37 + urlInput.value = tab.url; 38 + } 39 + updateTitleCounter(); 40 + } catch (error) { 41 + console.error("Unable to read active tab", error); 42 + } 43 + } 44 + 45 + let hasAuth = false; 46 + 47 + async function checkAuth() { 48 + try { 49 + const response = await api.runtime.sendMessage({ type: "frontpage-get-auth" }); 50 + hasAuth = Boolean(response?.auth); 51 + if (!hasAuth) { 52 + showStatus("Configure your Frontpage credentials in the options page.", true); 53 + } else { 54 + showStatus(""); 55 + } 56 + } catch (error) { 57 + console.error("Failed to query auth state", error); 58 + showStatus("Unable to read authentication state.", true); 59 + } 60 + } 61 + 62 + form.addEventListener("submit", async (event) => { 63 + event.preventDefault(); 64 + if (!hasAuth) { 65 + showStatus("Configure your Frontpage credentials in the options page.", true); 66 + return; 67 + } 68 + showStatus(""); 69 + submitBtn.disabled = true; 70 + try { 71 + const payload = { 72 + title: titleInput.value, 73 + url: urlInput.value 74 + }; 75 + const response = await api.runtime.sendMessage({ 76 + type: "frontpage-submit", 77 + payload 78 + }); 79 + if (!response?.ok) { 80 + throw new Error(response?.error ?? "Unknown error"); 81 + } 82 + const uri = response?.result?.uri; 83 + showStatus(uri ? `Posted! ${uri}` : "Posted to Frontpage!", false); 84 + } catch (error) { 85 + console.error("Submission failed", error); 86 + showStatus(error.message, true); 87 + } finally { 88 + submitBtn.disabled = false; 89 + } 90 + }); 91 + 92 + titleInput.addEventListener("input", updateTitleCounter); 93 + 94 + openOptionsBtn.addEventListener("click", () => { 95 + api.runtime.openOptionsPage(); 96 + }); 97 + 98 + openFrontpageLink.addEventListener("click", (event) => { 99 + event.preventDefault(); 100 + api.tabs.create({ url: "https://frontpage.fyi" }); 101 + }); 102 + 103 + populateFromTab(); 104 + checkAuth();
+155
extension/styles.css
··· 1 + * { 2 + box-sizing: border-box; 3 + } 4 + 5 + body { 6 + margin: 0; 7 + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; 8 + color: #0f172a; 9 + background: #f8fafc; 10 + } 11 + 12 + main.popup { 13 + width: 320px; 14 + padding: 16px; 15 + display: flex; 16 + flex-direction: column; 17 + gap: 12px; 18 + } 19 + 20 + main.popup header { 21 + display: flex; 22 + align-items: center; 23 + justify-content: space-between; 24 + } 25 + 26 + main.popup h1 { 27 + font-size: 1.1rem; 28 + margin: 0; 29 + } 30 + 31 + main.popup button, 32 + main.popup input { 33 + font: inherit; 34 + } 35 + 36 + #open-options { 37 + border: none; 38 + background: transparent; 39 + cursor: pointer; 40 + font-size: 1.1rem; 41 + line-height: 1; 42 + } 43 + 44 + form { 45 + display: flex; 46 + flex-direction: column; 47 + gap: 8px; 48 + } 49 + 50 + label { 51 + font-size: 0.85rem; 52 + font-weight: 600; 53 + display: flex; 54 + justify-content: space-between; 55 + align-items: baseline; 56 + } 57 + 58 + input { 59 + padding: 6px 8px; 60 + border: 1px solid #cbd5f5; 61 + border-radius: 6px; 62 + background: white; 63 + } 64 + 65 + button[type="submit"], 66 + button.secondary { 67 + padding: 8px 10px; 68 + border-radius: 6px; 69 + border: none; 70 + cursor: pointer; 71 + background: #2563eb; 72 + color: white; 73 + font-weight: 600; 74 + } 75 + 76 + button[disabled] { 77 + opacity: 0.6; 78 + cursor: progress; 79 + } 80 + 81 + button.secondary { 82 + background: #e2e8f0; 83 + color: #0f172a; 84 + border: 1px solid #cbd5f5; 85 + } 86 + 87 + .status { 88 + min-height: 1.2em; 89 + font-size: 0.85rem; 90 + } 91 + 92 + .status.error { 93 + color: #dc2626; 94 + } 95 + 96 + .status.success { 97 + color: #0f766e; 98 + } 99 + 100 + .counter { 101 + font-weight: normal; 102 + color: #64748b; 103 + } 104 + 105 + .over-limit { 106 + color: #b91c1c; 107 + } 108 + 109 + footer { 110 + font-size: 0.85rem; 111 + } 112 + 113 + main.options { 114 + max-width: 640px; 115 + margin: 0 auto; 116 + padding: 24px 16px 48px; 117 + display: flex; 118 + flex-direction: column; 119 + gap: 24px; 120 + } 121 + 122 + main.options h1 { 123 + margin-bottom: 8px; 124 + } 125 + 126 + main.options section { 127 + background: white; 128 + border-radius: 12px; 129 + padding: 16px 20px; 130 + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08); 131 + display: flex; 132 + flex-direction: column; 133 + gap: 16px; 134 + } 135 + 136 + main.options form { 137 + max-width: 400px; 138 + } 139 + 140 + main.options .tips { 141 + margin: 0; 142 + padding-left: 20px; 143 + color: #475569; 144 + font-size: 0.95rem; 145 + } 146 + 147 + .help { 148 + margin: 0; 149 + color: #475569; 150 + font-size: 0.9rem; 151 + } 152 + 153 + a { 154 + color: #2563eb; 155 + }