1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-22 03:05:32 +02:00

feat: Extensions settings tab + capture dialog redesign with Advanced options

Settings/Extensions tab (admin-only):
- New 'Extensions' tab between Cookies and Storage
- ExtensionsTab component: shows uBlock Origin Lite card with pill toggle
- Reads ublock_enabled from instance settings; patch via existing PATCH endpoint
- Shows ublock_ext_available status from server (whether ARCHIVR_UBLOCK_EXT is set)

Instance settings:
- Add ublock_enabled BOOLEAN (default true) to instance_settings auth DB table
- Idempotent ALTER TABLE migration in initialize_auth_schema()
- get/update_instance_settings include ublock_enabled
- GET /api/admin/instance-settings now also returns ublock_ext_available (computed
  from ARCHIVR_UBLOCK_EXT env var at request time)
- PATCH /api/admin/instance-settings accepts ublock_enabled

Per-capture override:
- CaptureBody gains ublock_enabled: Option<bool>
- CaptureConfig gains ublock_enabled: Option<bool>
- singlefile::save() gains ublock_enabled_override: Option<bool> param
- Capture handler resolves: body override > global instance setting > env var
- submitCapture(aid, loc, qual, extensions) in api.js passes ublock_enabled

Capture dialog redesign:
- Archive button: full-width, 13px padding, min-width 220px, primary CTA
- Cancel: full-width but text-style, below Archive
- ‹Advanced options› chevron toggle (rotates on open)
- Expanded panel shows uBlock toggle for this capture session
- Loads global ublock_enabled default from instance settings on mount

Styles:
- .ext-toggle pill switch (44×24 and 36×20 small variant)
- .ext-card for Settings Extensions tab
- .capture-advanced + .capture-advanced-panel + .capture-chevron
- .capture-ext-row / .capture-ext-label / .capture-ext-name / .capture-ext-desc
- .form-hint utility class
This commit is contained in:
TheGeneralist 2026-07-07 17:33:35 +02:00
parent 8a90259a76
commit 1b3543ead8
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
13 changed files with 400 additions and 87 deletions

View file

@ -77,6 +77,11 @@ impl PlatformMetadata {
#[derive(Debug, Clone, Default)]
pub struct CaptureConfig {
pub cookie_rules: Vec<database::CookieRule>,
/// Override for uBlock Origin Lite during WebPage captures.
/// `None` = derive from `ARCHIVR_UBLOCK` env var (existing behaviour).
/// `Some(false)` = disabled by admin/user choice for this capture.
/// `Some(true)` = explicitly enabled (still needs `ARCHIVR_UBLOCK_EXT` to be valid).
pub ublock_enabled: Option<bool>,
}
/// Resolves which cookies apply to `url` by evaluating all rules in ordinal order.
@ -1010,7 +1015,7 @@ pub fn perform_capture(
// Source: web page — archive as a self-contained HTML snapshot via single-file-cli
if source == Source::WebPage {
match downloader::singlefile::save(locator, store_path, &timestamp, &cookies) {
match downloader::singlefile::save(locator, store_path, &timestamp, &cookies, config.ublock_enabled) {
Ok(result) => {
let file_extension = ".html".to_string();
let temp_html = store_path

View file

@ -137,6 +137,9 @@ pub struct InstanceSettings {
pub public_entry_content_enabled: bool,
pub open_registration_enabled: bool, // maps to public_archive_submission_enabled column
pub default_entry_visibility: u32,
/// Global default for ad-blocking via uBlock Origin Lite during WebPage captures.
/// Per-capture requests can override this.
pub ublock_enabled: bool,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@ -460,13 +463,14 @@ pub fn initialize_auth_schema(conn: &Connection) -> Result<()> {
public_index_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_index_enabled IN (0, 1)),
public_entry_content_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_entry_content_enabled IN (0, 1)),
public_archive_submission_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_archive_submission_enabled IN (0, 1)),
default_entry_visibility INTEGER NOT NULL DEFAULT 2
default_entry_visibility INTEGER NOT NULL DEFAULT 2,
ublock_enabled INTEGER NOT NULL DEFAULT 1 CHECK (ublock_enabled IN (0, 1))
);
INSERT OR IGNORE INTO instance_settings
(id, public_index_enabled, public_entry_content_enabled,
public_archive_submission_enabled, default_entry_visibility)
VALUES (1, 0, 0, 0, 2);
public_archive_submission_enabled, default_entry_visibility, ublock_enabled)
VALUES (1, 0, 0, 0, 2, 1);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
@ -500,6 +504,12 @@ pub fn initialize_auth_schema(conn: &Connection) -> Result<()> {
[],
);
// Add ublock_enabled column to instance_settings if not present (idempotent migration)
let _ = conn.execute(
"ALTER TABLE instance_settings ADD COLUMN ublock_enabled INTEGER NOT NULL DEFAULT 1",
[],
);
Ok(())
}
@ -724,7 +734,8 @@ pub fn list_user_tokens(conn: &Connection, user_id: i64) -> Result<Vec<ApiTokenR
pub fn get_instance_settings(conn: &Connection) -> Result<InstanceSettings> {
conn.query_row(
"SELECT public_index_enabled, public_entry_content_enabled,
public_archive_submission_enabled, default_entry_visibility
public_archive_submission_enabled, default_entry_visibility,
COALESCE(ublock_enabled, 1)
FROM instance_settings WHERE id = 1",
[],
|row| {
@ -733,6 +744,7 @@ pub fn get_instance_settings(conn: &Connection) -> Result<InstanceSettings> {
public_entry_content_enabled: row.get::<_, i64>(1)? != 0,
open_registration_enabled: row.get::<_, i64>(2)? != 0,
default_entry_visibility: row.get::<_, i64>(3)? as u32,
ublock_enabled: row.get::<_, i64>(4)? != 0,
})
},
)
@ -745,13 +757,15 @@ pub fn update_instance_settings(conn: &Connection, settings: &InstanceSettings)
SET public_index_enabled = ?1,
public_entry_content_enabled = ?2,
public_archive_submission_enabled = ?3,
default_entry_visibility = ?4
default_entry_visibility = ?4,
ublock_enabled = ?5
WHERE id = 1",
params![
settings.public_index_enabled as i64,
settings.public_entry_content_enabled as i64,
settings.open_registration_enabled as i64,
settings.default_entry_visibility as i64,
settings.ublock_enabled as i64,
],
)?;
Ok(())

View file

@ -43,11 +43,12 @@ pub fn save(
store_path: &Path,
timestamp: &str,
cookies: &HashMap<String, String>,
ublock_enabled_override: Option<bool>,
) -> Result<SaveResult> {
let single_file =
env::var("ARCHIVR_SINGLE_FILE").unwrap_or_else(|_| "single-file".to_string());
let chrome = env::var("ARCHIVR_CHROME").unwrap_or_else(|_| "chromium".to_string());
let (ublock_ext, ublock_skipped) = resolve_ublock_config();
let (ublock_ext, ublock_skipped) = resolve_ublock_config(ublock_enabled_override);
let mut result = save_with(
url,
store_path,
@ -61,21 +62,26 @@ pub fn save(
Ok(result)
}
/// Reads `ARCHIVR_UBLOCK` and `ARCHIVR_UBLOCK_EXT` and returns:
/// Resolves uBlock configuration from env vars, optionally overridden by the caller.
///
/// Returns:
/// - `(Some(path), false)` — uBlock is enabled and the extension dir is valid.
/// - `(None, true)` — uBlock is enabled but the extension dir is missing/invalid
/// (warns to stderr; caller should surface this to the user).
/// - `(None, false)` — uBlock is explicitly disabled (`ARCHIVR_UBLOCK=false/0`).
fn resolve_ublock_config() -> (Option<PathBuf>, bool) {
let enabled = env::var("ARCHIVR_UBLOCK").unwrap_or_else(|_| "true".to_string());
if enabled.eq_ignore_ascii_case("false") || enabled == "0" {
/// (warns to stderr; the capture proceeds without ad-blocking).
/// - `(None, false)` — uBlock is disabled (`ARCHIVR_UBLOCK=false` or overridden).
fn resolve_ublock_config(enabled_override: Option<bool>) -> (Option<PathBuf>, bool) {
// The override (from instance settings or per-capture body) takes precedence over env.
let want_ublock = enabled_override.unwrap_or_else(|| {
let env_val = env::var("ARCHIVR_UBLOCK").unwrap_or_else(|_| "true".to_string());
!env_val.eq_ignore_ascii_case("false") && env_val != "0"
});
if !want_ublock {
return (None, false);
}
match env::var("ARCHIVR_UBLOCK_EXT").ok().filter(|s| !s.is_empty()) {
None => {
eprintln!(
"warn: uBlock: ARCHIVR_UBLOCK=true but ARCHIVR_UBLOCK_EXT is not set; \
"warn: uBlock: ARCHIVR_UBLOCK_EXT is not set; \
capturing without ad-blocking"
);
(None, true)

View file

@ -653,6 +653,8 @@ struct CaptureBody {
/// Optional quality cap for yt-dlp sources: `"best"` or any `"NNNp"` string
/// (e.g. `"1080p"`, `"720p"`, `"2160p"`). Absent or `"best"` → highest available.
quality: Option<String>,
/// Per-capture uBlock override. Absent → use global instance setting.
ublock_enabled: Option<bool>,
}
#[derive(Debug, serde::Deserialize)]
@ -733,14 +735,26 @@ async fn capture_handler(
let job_uid = database::create_capture_job(&conn, &archive_id)?;
drop(conn);
// Load cookie rules from the auth DB to pass into the capture background task.
let cookie_rules = {
// Load cookie rules and global uBlock setting from the auth DB.
let (cookie_rules, global_ublock) = {
match database::open_auth_db(&state.auth_db_path) {
Ok(conn) => database::list_cookie_rules(&conn).unwrap_or_default(),
Err(_) => vec![],
Ok(conn) => {
let rules = database::list_cookie_rules(&conn).unwrap_or_default();
let ublock = database::get_instance_settings(&conn)
.map(|s| s.ublock_enabled)
.unwrap_or(true);
(rules, ublock)
}
Err(_) => (vec![], true),
}
};
let capture_config = capture::CaptureConfig { cookie_rules };
// Per-capture body overrides global; if body doesn't specify, use the global setting.
// The resolved bool is then passed as Some(_) to singlefile, overriding the env var.
let effective_ublock = body.ublock_enabled.unwrap_or(global_ublock);
let capture_config = capture::CaptureConfig {
cookie_rules,
ublock_enabled: Some(effective_ublock),
};
// Spawn background capture.
let locator = body.locator.trim().to_string();
@ -1020,10 +1034,20 @@ async fn patch_me(
async fn get_instance_settings_handler(
State(state): State<AppState>,
auth_user: AuthUser,
) -> Result<Json<database::InstanceSettings>, ApiError> {
) -> Result<Json<serde_json::Value>, ApiError> {
auth_user.require_role(ROLE_ADMIN)?;
let conn = database::open_auth_db(&state.auth_db_path)?;
Ok(Json(database::get_instance_settings(&conn)?))
let settings = database::get_instance_settings(&conn)?;
let ublock_ext_available = std::env::var("ARCHIVR_UBLOCK_EXT")
.ok()
.filter(|s| !s.is_empty())
.map(|p| std::path::Path::new(&p).is_dir())
.unwrap_or(false);
let mut val = serde_json::to_value(&settings).unwrap_or_default();
if let Some(obj) = val.as_object_mut() {
obj.insert("ublock_ext_available".into(), serde_json::Value::Bool(ublock_ext_available));
}
Ok(Json(val))
}
async fn update_instance_settings_handler(
@ -1038,6 +1062,7 @@ async fn update_instance_settings_handler(
if let Some(v) = body.public_entry_content_enabled { settings.public_entry_content_enabled = v; }
if let Some(v) = body.open_registration_enabled { settings.open_registration_enabled = v; }
if let Some(v) = body.default_entry_visibility { settings.default_entry_visibility = v; }
if let Some(v) = body.ublock_enabled { settings.ublock_enabled = v; }
database::update_instance_settings(&conn, &settings)?;
Ok(StatusCode::NO_CONTENT)
}
@ -1367,6 +1392,7 @@ struct UpdateInstanceSettingsBody {
public_entry_content_enabled: Option<bool>,
open_registration_enabled: Option<bool>,
default_entry_visibility: Option<u32>,
ublock_enabled: Option<bool>,
}
async fn admin_list_users(

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,8 +4,8 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Archivr</title>
<script type="module" crossorigin src="/assets/index-BpXoSc97.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BGSI2WEx.css">
<script type="module" crossorigin src="/assets/index-BB_JhtCc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C0BIEdow.css">
</head>
<body>
<div id="root"></div>

View file

@ -95,9 +95,13 @@ export async function fetchTags(archiveId) {
return getJson(`/api/archives/${archiveId}/tags`);
}
export async function submitCapture(archiveId, locator, quality = null) {
export async function submitCapture(archiveId, locator, quality = null, extensions = null) {
const payload = { locator }
if (quality && quality !== 'best') payload.quality = quality
// extensions: { ublock_enabled?: bool } — per-capture overrides
if (extensions && typeof extensions.ublock_enabled === 'boolean') {
payload.ublock_enabled = extensions.ublock_enabled
}
const res = await fetch(`/api/archives/${archiveId}/captures`, {
method: "POST",
headers: { "Content-Type": "application/json" },

View file

@ -1,5 +1,5 @@
import { useRef, useEffect, useState } from 'react'
import { submitCapture, pollCaptureJob, probeCapture } from '../api'
import { useRef, useEffect, useState, useCallback } from 'react'
import { submitCapture, pollCaptureJob, probeCapture, getInstanceSettings } from '../api'
let nextItemId = 1
@ -112,6 +112,23 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
sessionStorage.setItem('captureItems', JSON.stringify(items))
}, [items])
// Advanced options panel state
const [advancedOpen, setAdvancedOpen] = useState(false)
// null = use server default; true/false = per-session override
const [ublockOverride, setUblockOverride] = useState(null)
// Server-side global default (loaded on mount, null until loaded)
const [globalUblock, setGlobalUblock] = useState(null)
// Load global uBlock setting from server once on mount
useEffect(() => {
getInstanceSettings()
.then(s => setGlobalUblock(s.ublock_enabled ?? true))
.catch(() => setGlobalUblock(true)) // safe default
}, [])
// Effective uBlock for this session
const ublockEnabled = ublockOverride !== null ? ublockOverride : (globalUblock ?? true)
// On mount: clean up old single-locator sessionStorage keys; reconnect running jobs
useEffect(() => {
;['captureDialogLocator','captureDialogError','captureDialogBusy',
@ -223,7 +240,8 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
const qual = item.quality || 'best'
setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'submitting', error: null } : it))
try {
const job = await submitCapture(aid, loc, qual)
const extensions = { ublock_enabled: ublockEnabled }
const job = await submitCapture(aid, loc, qual, extensions)
setItems(prev => prev.map(it =>
it.id === item.id ? { ...it, status: 'running', jobUid: job.job_uid, archiveId: aid } : it
))
@ -342,10 +360,47 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
Add another
</button>
<div className="capture-actions">
<button type="button" className="capture-cancel" onClick={() => dialogRef.current?.close()}>
{anyActive ? 'Close' : 'Cancel'}
{/* ── Advanced options ────────────────────────────── */}
<div className="capture-advanced">
<button
type="button"
className="capture-advanced-toggle"
onClick={() => setAdvancedOpen(v => !v)}
aria-expanded={advancedOpen}
>
<svg
className={`capture-chevron${advancedOpen ? ' capture-chevron--open' : ''}`}
viewBox="0 0 16 16" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
>
<polyline points="4 6 8 10 12 6"/>
</svg>
Advanced options
</button>
{advancedOpen && (
<div className="capture-advanced-panel">
<label className="capture-ext-row">
<span className="capture-ext-label">
<span className="capture-ext-name">uBlock Origin Lite</span>
<span className="capture-ext-desc">Block ads during this capture</span>
</span>
<button
type="button"
role="switch"
aria-checked={ublockEnabled}
className={`ext-toggle ext-toggle--sm${ublockEnabled ? ' ext-toggle--on' : ''}`}
onClick={() => setUblockOverride(v => v === null ? !ublockEnabled : !v)}
aria-label="Toggle uBlock for this capture"
>
<span className="ext-toggle-knob" />
</button>
</label>
</div>
)}
</div>
{/* ── Primary action ──────────────────────────────── */}
<div className="capture-actions">
<button
type="button"
className="capture-submit"
@ -354,6 +409,9 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
>
{pendingCount > 1 ? `Archive ${pendingCount}` : 'Archive'}
</button>
<button type="button" className="capture-cancel" onClick={() => dialogRef.current?.close()}>
{anyActive ? 'Close' : 'Cancel'}
</button>
</div>
</div>
</dialog>

View file

@ -14,8 +14,8 @@ export default function SettingsView({ tab, onTabChange, archiveId }) {
const { currentUser, setCurrentUser } = useContext(AuthContext) ?? {}
const isAdmin = currentUser && ((currentUser.role_bits & ROLE_ADMIN) !== 0)
const tabs = ['profile', 'tokens', ...(isAdmin ? ['instance', 'cookies', 'storage'] : [])]
const tabLabels = { profile: 'Profile', tokens: 'API Tokens', instance: 'Instance', cookies: 'Cookies', storage: 'Storage' }
const tabs = ['profile', 'tokens', ...(isAdmin ? ['instance', 'cookies', 'extensions', 'storage'] : [])]
const tabLabels = { profile: 'Profile', tokens: 'API Tokens', instance: 'Instance', cookies: 'Cookies', extensions: 'Extensions', storage: 'Storage' }
return (
<section className="admin-view">
@ -34,6 +34,7 @@ export default function SettingsView({ tab, onTabChange, archiveId }) {
{tab === 'tokens' && <TokensTab />}
{tab === 'instance' && isAdmin && <InstanceTab />}
{tab === 'cookies' && isAdmin && <CookiesTab />}
{tab === 'extensions' && isAdmin && <ExtensionsTab />}
{tab === 'storage' && isAdmin && <StorageTab archiveId={archiveId} />}
</section>
)
@ -639,3 +640,80 @@ function CookiesTab() {
</div>
)
}
function ExtensionsTab() {
const [settings, setSettings] = useState(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState(null)
useEffect(() => {
(async () => {
try { setSettings(await getInstanceSettings()) }
catch (e) { setMsg({ ok: false, text: e.message }) }
finally { setLoading(false) }
})()
}, [])
async function toggleUblock(val) {
setSaving(true)
setMsg(null)
try {
await updateInstanceSettings({ ublock_enabled: val })
setSettings(s => ({ ...s, ublock_enabled: val }))
setMsg({ ok: true, text: 'Saved.' })
} catch (e) {
setMsg({ ok: false, text: e.message })
} finally {
setSaving(false)
}
}
if (loading) return <div className="muted">Loading\u2026</div>
const extAvailable = settings?.ublock_ext_available ?? false
const extEnabled = settings?.ublock_enabled ?? true
return (
<div style={{ maxWidth: 560 }}>
<div className="form-section">
<h2>Extensions</h2>
<p className="form-hint" style={{ marginBottom: 20 }}>
Extensions run inside the browser during WebPage captures and can block ads,
accept cookie banners, and more. Changes take effect on the next capture.
</p>
<div className="ext-card">
<div className="ext-card-header">
<div className="ext-card-info">
<span className="ext-card-name">uBlock Origin Lite</span>
<span className="ext-card-desc">
Blocks ads, trackers, and other page clutter during archiving
via Chrome&rsquo;s declarativeNetRequest API (Manifest V3).
</span>
{!extAvailable && (
<span className="ext-card-hint">
Not configured &mdash; set <code>ARCHIVR_UBLOCK_EXT</code> to the
unpacked extension directory to enable.
</span>
)}
</div>
<button
type="button"
role="switch"
aria-checked={extEnabled}
className={`ext-toggle${extEnabled ? ' ext-toggle--on' : ''}`}
onClick={() => toggleUblock(!extEnabled)}
disabled={saving}
aria-label="Toggle uBlock Origin Lite"
>
<span className="ext-toggle-knob" />
</button>
</div>
</div>
{msg && <div className={`form-msg form-msg--${msg.ok ? 'ok' : 'err'}`}>{msg.text}</div>}
</div>
</div>
)
}

View file

@ -608,30 +608,86 @@ select {
}
.capture-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 16px;
flex-direction: column;
gap: 8px;
margin-top: 18px;
}
.capture-cancel {
border: 1px solid var(--line);
background: none;
color: var(--ink);
padding: 8px 18px;
border-radius: var(--r);
cursor: pointer;
}
.capture-cancel:hover { background: var(--paper-2); }
.capture-submit {
border: 0;
background: var(--ink);
color: var(--paper);
padding: 8px 20px;
border-radius: var(--r);
padding: 13px 20px;
border-radius: var(--r2);
cursor: pointer;
font-weight: 600;
font-size: 15px;
width: 100%;
min-width: 220px;
letter-spacing: 0.01em;
}
.capture-submit:hover { opacity: 0.85; }
.capture-submit:disabled { opacity: 0.45; cursor: default; }
.capture-cancel {
border: 0;
background: none;
color: var(--muted);
padding: 7px 18px;
border-radius: var(--r);
cursor: pointer;
font-size: 13px;
text-align: center;
width: 100%;
}
.capture-cancel:hover { color: var(--ink); background: var(--paper-2); }
/* ── Capture advanced options ────────────────────────────────────────────── */
.capture-advanced { margin-top: 12px; }
.capture-advanced-toggle {
display: flex;
align-items: center;
gap: 5px;
background: none;
border: 0;
cursor: pointer;
color: var(--muted);
font-size: 12.5px;
padding: 4px 0;
border-radius: var(--r);
}
.capture-advanced-toggle:hover { color: var(--ink); }
.capture-chevron {
width: 14px; height: 14px;
flex-shrink: 0;
transition: transform 0.18s ease;
}
.capture-chevron--open { transform: rotate(180deg); }
.capture-advanced-panel {
margin-top: 8px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: var(--r2);
background: var(--paper-2);
}
.capture-ext-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
cursor: default;
}
.capture-ext-label { flex: 1; min-width: 0; }
.capture-ext-name {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--ink);
}
.capture-ext-desc {
display: block;
font-size: 11.5px;
color: var(--muted);
margin-top: 1px;
}
/* ── Capture dialog: header + multi-row ─────────────────────────────────── */
.capture-dialog-header {
@ -906,6 +962,72 @@ select {
}
.toast-ignore-btn { color: var(--muted); }
/* ── Extension toggle (pill switch) ─────────────────────────────────────── */
.ext-toggle {
flex-shrink: 0;
position: relative;
width: 44px;
height: 24px;
border-radius: 12px;
background: var(--line);
border: 0;
cursor: pointer;
transition: background 0.18s ease;
padding: 0;
}
.ext-toggle--sm { width: 36px; height: 20px; border-radius: 10px; }
.ext-toggle--on { background: var(--ink); }
.ext-toggle:disabled { opacity: 0.4; cursor: default; }
.ext-toggle-knob {
position: absolute;
top: 3px; left: 3px;
width: 18px; height: 18px;
border-radius: 50%;
background: var(--paper);
transition: transform 0.18s ease;
display: block;
box-shadow: 0 1px 3px rgba(0,0,0,.2);
}
.ext-toggle--sm .ext-toggle-knob { width: 14px; height: 14px; top: 3px; left: 3px; }
.ext-toggle--on .ext-toggle-knob { transform: translateX(20px); }
.ext-toggle--sm.ext-toggle--on .ext-toggle-knob { transform: translateX(16px); }
/* ── Extension card (Settings / Extensions tab) ──────────────────────────── */
.ext-card {
border: 1px solid var(--line);
border-radius: var(--r2);
padding: 16px 18px;
background: var(--paper);
}
.ext-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.ext-card-info { flex: 1; min-width: 0; }
.ext-card-name {
display: block;
font-weight: 600;
font-size: 14px;
margin-bottom: 4px;
}
.ext-card-desc {
display: block;
font-size: 13px;
color: var(--muted);
line-height: 1.5;
}
.ext-card-hint {
display: block;
font-size: 12px;
color: #e8a000;
margin-top: 6px;
}
.ext-card-hint code { font-size: 11.5px; }
.form-hint { font-size: 13px; color: var(--muted); line-height: 1.55; margin: 0; }
/* ── Utility ─────────────────────────────────────────────────────────────── */
.muted { color: var(--muted); }