+36
.gitignore
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+
}