diff --git a/extensions/diffkit-redirect/README.md b/extensions/diffkit-redirect/README.md new file mode 100644 index 0000000..1e40264 --- /dev/null +++ b/extensions/diffkit-redirect/README.md @@ -0,0 +1,81 @@ +# DiffKit Extension + +Standalone browser extension for redirecting only selected GitHub URLs to DiffKit. + +## What it does + +- Redirects matching GitHub URLs at page start +- Lets you keep the extension globally enabled or disabled +- Shows one toggle per redirect rule in the popup +- Uses a configurable list of rules instead of redirecting every GitHub page +- Supports both exact URL redirects and regex-based route schemas +- Supports custom route remaps like GitHub PR changes pages to DiffKit review pages + +## Default rule + +The extension ships with these enabled rules: + +- `https://github.com/` +- `https://diff-kit.com/` +- `https://github.com/pulls/*` +- `https://diff-kit.com/pulls` +- `https://github.com/issues/*` +- `https://diff-kit.com/issues` +- `https://github.com/:owner/:repo/pull/:number` +- `https://diff-kit.com/:owner/:repo/pull/:number` +- `https://github.com/:owner/:repo/pull/:number/changes` +- `https://diff-kit.com/:owner/:repo/review/:number` +- `https://github.com/:owner/:repo/issues/:number` +- `https://diff-kit.com/:owner/:repo/issues/:number` + +## Rule format + +```json +[ + { + "id": "github-pull-details", + "label": "Pull request details", + "description": "Redirect GitHub pull request detail pages to DiffKit.", + "enabled": true, + "match": { + "urlRegex": "^https://github\\.com/([^/?#]+)/([^/?#]+)/pull/(\\d+)/?$", + "excludeUrlRegexes": [] + }, + "redirect": { + "replacement": "https://diff-kit.com/$1/$2/pull/$3" + } + } +] +``` + +For an exact URL redirect, use: + +```json +{ + "id": "one-off", + "label": "Specific PR page", + "description": "Single URL redirect", + "enabled": true, + "match": { + "exactUrl": "https://github.com/org/repo/pull/123" + }, + "redirect": { + "url": "https://diff-kit.com/org/repo/pull/123" + } +} +``` + +## Install locally + +1. Open `chrome://extensions` +2. Enable Developer mode +3. Click Load unpacked +4. Select `extensions/diffkit-redirect` + +## Scope + +This version is intentionally limited to `github.com` in `manifest.json`. If you later want redirects from other source hosts, add those hosts to the extension matches and permissions. + +## Popup behavior + +The popup keeps all default rules enabled unless you turn specific ones off. Use the master switch to pause all redirects without losing your per-rule selections. diff --git a/extensions/diffkit-redirect/content.js b/extensions/diffkit-redirect/content.js new file mode 100644 index 0000000..3e37113 --- /dev/null +++ b/extensions/diffkit-redirect/content.js @@ -0,0 +1,22 @@ +(async function runRedirect() { + const shared = globalThis.DiffKitRedirect; + if (!shared) { + return; + } + + try { + const config = await shared.getConfig(); + if (!config.enabled) { + return; + } + + const redirect = shared.findRedirect(window.location.href, config.rules); + if (!redirect) { + return; + } + + window.location.replace(redirect.targetUrl); + } catch (error) { + console.error("DiffKit: failed to evaluate redirect.", error); + } +})(); diff --git a/extensions/diffkit-redirect/icons/icon-128.png b/extensions/diffkit-redirect/icons/icon-128.png new file mode 100644 index 0000000..6d072c1 Binary files /dev/null and b/extensions/diffkit-redirect/icons/icon-128.png differ diff --git a/extensions/diffkit-redirect/icons/icon-16.png b/extensions/diffkit-redirect/icons/icon-16.png new file mode 100644 index 0000000..ee77780 Binary files /dev/null and b/extensions/diffkit-redirect/icons/icon-16.png differ diff --git a/extensions/diffkit-redirect/icons/icon-32.png b/extensions/diffkit-redirect/icons/icon-32.png new file mode 100644 index 0000000..9cd8e62 Binary files /dev/null and b/extensions/diffkit-redirect/icons/icon-32.png differ diff --git a/extensions/diffkit-redirect/icons/icon-48.png b/extensions/diffkit-redirect/icons/icon-48.png new file mode 100644 index 0000000..65d9ada Binary files /dev/null and b/extensions/diffkit-redirect/icons/icon-48.png differ diff --git a/extensions/diffkit-redirect/icons/logo.svg b/extensions/diffkit-redirect/icons/logo.svg new file mode 100644 index 0000000..3550753 --- /dev/null +++ b/extensions/diffkit-redirect/icons/logo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/extensions/diffkit-redirect/manifest.json b/extensions/diffkit-redirect/manifest.json new file mode 100644 index 0000000..feec289 --- /dev/null +++ b/extensions/diffkit-redirect/manifest.json @@ -0,0 +1,30 @@ +{ + "manifest_version": 3, + "name": "DiffKit", + "version": "0.1.0", + "description": "Redirect selected GitHub URLs to matching DiffKit routes.", + "icons": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + }, + "permissions": ["storage"], + "host_permissions": ["https://github.com/*"], + "action": { + "default_title": "DiffKit", + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png" + } + }, + "options_page": "options.html", + "content_scripts": [ + { + "matches": ["https://github.com/*"], + "js": ["shared.js", "content.js"], + "run_at": "document_start" + } + ] +} diff --git a/extensions/diffkit-redirect/options.html b/extensions/diffkit-redirect/options.html new file mode 100644 index 0000000..6fc8528 --- /dev/null +++ b/extensions/diffkit-redirect/options.html @@ -0,0 +1,74 @@ + + + + + + DiffKit Options + + + +
+
+
+
+

DiffKit

+

Redirect rules

+
+ + +
+ +

+ Add only the GitHub routes DiffKit currently supports. A rule can + match a single exact URL or a regex pattern over the full URL. +

+ + + +
+ + +
+ +

+
+ + +
+ + + + + diff --git a/extensions/diffkit-redirect/options.js b/extensions/diffkit-redirect/options.js new file mode 100644 index 0000000..f310f5b --- /dev/null +++ b/extensions/diffkit-redirect/options.js @@ -0,0 +1,58 @@ +(async function initOptions() { + const shared = globalThis.DiffKitRedirect; + const enabledToggle = document.getElementById("enabled-toggle"); + const rulesEditor = document.getElementById("rules-editor"); + const saveButton = document.getElementById("save-button"); + const resetButton = document.getElementById("reset-button"); + const saveStatus = document.getElementById("save-status"); + + function setStatus(message, tone) { + saveStatus.textContent = message; + saveStatus.dataset.tone = tone || ""; + } + + async function loadConfig() { + const config = await shared.getConfig(); + enabledToggle.checked = config.enabled; + rulesEditor.value = JSON.stringify(config.rules, null, 2); + + if (config.validationErrors.length > 0) { + setStatus(config.validationErrors.join(" "), "error"); + return; + } + + setStatus("", ""); + } + + async function saveConfig() { + let parsedRules; + try { + parsedRules = JSON.parse(rulesEditor.value); + } catch { + setStatus("Rules must be valid JSON.", "error"); + return; + } + + const result = await shared.saveConfig({ + enabled: enabledToggle.checked, + rules: parsedRules, + }); + + if (!result.ok) { + setStatus(result.errors.join(" "), "error"); + return; + } + + setStatus("Rules saved.", "success"); + await loadConfig(); + } + + saveButton.addEventListener("click", saveConfig); + + resetButton.addEventListener("click", () => { + rulesEditor.value = JSON.stringify(shared.getDefaultRules(), null, 2); + setStatus("Defaults restored in the editor. Save to apply them.", ""); + }); + + await loadConfig(); +})(); diff --git a/extensions/diffkit-redirect/popup.html b/extensions/diffkit-redirect/popup.html new file mode 100644 index 0000000..60dbd33 --- /dev/null +++ b/extensions/diffkit-redirect/popup.html @@ -0,0 +1,41 @@ + + + + + + DiffKit + + + +
+ +
+ + + + + diff --git a/extensions/diffkit-redirect/popup.js b/extensions/diffkit-redirect/popup.js new file mode 100644 index 0000000..160ff82 --- /dev/null +++ b/extensions/diffkit-redirect/popup.js @@ -0,0 +1,137 @@ +(async function initPopup() { + const shared = globalThis.DiffKitRedirect; + const toggle = document.getElementById("enabled-toggle"); + const ruleList = document.getElementById("rule-list"); + const statusCopy = document.getElementById("status-copy"); + + function getRuleLabel(rule) { + if (rule.label) { + return rule.label; + } + + if (rule.description) { + return rule.description; + } + + return rule.id + .replace(/[-_]+/g, " ") + .replace(/\b\w/g, (character) => character.toUpperCase()); + } + + function getRuleDescription(rule) { + if (rule.enabled) { + return "Enabled"; + } + + return "Disabled"; + } + + function renderRuleList(config) { + ruleList.textContent = ""; + + if (config.rules.length === 0) { + const emptyState = document.createElement("p"); + emptyState.className = "empty-state"; + emptyState.textContent = "No redirect rules available yet."; + ruleList.append(emptyState); + return; + } + + for (const rule of config.rules) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "rule-toggle"; + button.dataset.ruleId = rule.id; + button.setAttribute("aria-pressed", String(rule.enabled)); + + const label = document.createElement("span"); + label.className = "rule-copy"; + + const title = document.createElement("span"); + title.className = "rule-title"; + title.textContent = getRuleLabel(rule); + + const description = document.createElement("span"); + description.className = "rule-description"; + description.textContent = getRuleDescription(rule); + + label.append(title, description); + button.append(label); + ruleList.append(button); + } + } + + function renderStatus(config) { + const activeRules = config.rules.filter((rule) => rule.enabled).length; + const totalRules = config.rules.length; + + if (!config.enabled) { + statusCopy.textContent = `Master off. ${activeRules} of ${totalRules} rules selected.`; + return; + } + + statusCopy.textContent = `${activeRules} of ${totalRules} redirects active.`; + } + + async function render() { + const config = await shared.getConfig(); + toggle.checked = config.enabled; + renderRuleList(config); + renderStatus(config); + } + + try { + await render(); + } catch { + statusCopy.textContent = "Failed to load extension state"; + } + + toggle.addEventListener("change", async () => { + const config = await shared.getConfig(); + const nextConfig = { + enabled: toggle.checked, + rules: config.rules, + }; + + const result = await shared.saveConfig(nextConfig); + if (!result.ok) { + statusCopy.textContent = result.errors.join(" "); + return; + } + + await render(); + }); + + ruleList.addEventListener("click", async (event) => { + if (!(event.target instanceof Element)) { + return; + } + + const button = event.target.closest(".rule-toggle"); + if (!button) { + return; + } + + const config = await shared.getConfig(); + const nextRules = config.rules.map((rule) => + rule.id === button.dataset.ruleId + ? { + ...rule, + enabled: !rule.enabled, + } + : rule + ); + + const result = await shared.saveConfig({ + enabled: config.enabled, + rules: nextRules, + }); + + if (!result.ok) { + statusCopy.textContent = result.errors.join(" "); + return; + } + + await render(); + }); +})(); diff --git a/extensions/diffkit-redirect/shared.js b/extensions/diffkit-redirect/shared.js new file mode 100644 index 0000000..f3f4352 --- /dev/null +++ b/extensions/diffkit-redirect/shared.js @@ -0,0 +1,400 @@ +(function initDiffKitRedirectShared(global) { + const extensionApi = global.chrome ?? global.browser; + + const STORAGE_KEYS = { + enabled: "enabled", + rules: "rules", + }; + + const DEFAULT_RULES = [ + { + id: "github-overview", + label: "Overview page", + description: "Redirect the GitHub overview page to DiffKit.", + enabled: true, + match: { + urlRegex: "^https://github\\.com/?(?:[?#].*)?$", + }, + redirect: { + url: "https://diff-kit.com/", + }, + }, + { + id: "github-pulls", + label: "Pulls list", + description: "Redirect the GitHub pulls page to DiffKit pulls.", + enabled: true, + match: { + urlRegex: "^https://github\\.com/pulls(?:/.*)?(?:[?#].*)?$", + }, + redirect: { + url: "https://diff-kit.com/pulls", + }, + }, + { + id: "github-global-issues", + label: "Issues list", + description: "Redirect GitHub global issues pages to DiffKit issues.", + enabled: true, + match: { + urlRegex: "^https://github\\.com/issues(?:/.*)?(?:[?#].*)?$", + }, + redirect: { + url: "https://diff-kit.com/issues", + }, + }, + { + id: "github-pull-details", + label: "Pull request details", + description: "Redirect GitHub pull request detail pages to DiffKit.", + enabled: true, + match: { + urlRegex: "^https://github\\.com/([^/?#]+)/([^/?#]+)/pull/(\\d+)/?$", + }, + redirect: { + replacement: "https://diff-kit.com/$1/$2/pull/$3", + }, + }, + { + id: "github-review-details", + label: "Code review page", + description: "Redirect GitHub PR changes pages to DiffKit review pages.", + enabled: true, + match: { + urlRegex: + "^https://github\\.com/([^/?#]+)/([^/?#]+)/pull/(\\d+)/changes/?$", + }, + redirect: { + replacement: "https://diff-kit.com/$1/$2/review/$3", + }, + }, + { + id: "github-issue-details", + label: "Issue details", + description: "Redirect GitHub issue detail pages to DiffKit.", + enabled: true, + match: { + urlRegex: "^https://github\\.com/([^/?#]+)/([^/?#]+)/issues/(\\d+)/?$", + }, + redirect: { + replacement: "https://diff-kit.com/$1/$2/issues/$3", + }, + }, + ]; + + function deepClone(value) { + return JSON.parse(JSON.stringify(value)); + } + + function callApi(method, ...args) { + return new Promise((resolve, reject) => { + let settled = false; + const callback = (result) => { + if (settled) { + return; + } + + settled = true; + const runtimeError = extensionApi.runtime?.lastError; + if (runtimeError) { + reject(new Error(runtimeError.message)); + return; + } + resolve(result); + }; + + try { + const maybePromise = method(...args, callback); + if (maybePromise && typeof maybePromise.then === "function") { + maybePromise.then( + (result) => { + if (settled) { + return; + } + settled = true; + resolve(result); + }, + (error) => { + if (settled) { + return; + } + settled = true; + reject(error); + } + ); + } + } catch (error) { + reject(error); + } + }); + } + + function storageGet(keys) { + return callApi( + extensionApi.storage.sync.get.bind(extensionApi.storage.sync), + keys + ); + } + + function storageSet(values) { + return callApi( + extensionApi.storage.sync.set.bind(extensionApi.storage.sync), + values + ); + } + + function isNonEmptyString(value) { + return typeof value === "string" && value.trim().length > 0; + } + + function normalizeStringArray(value) { + if (!Array.isArray(value)) { + return []; + } + + return value.filter(isNonEmptyString); + } + + function normalizeRule(rawRule, index) { + const fallbackId = `rule-${index + 1}`; + const rule = { + id: isNonEmptyString(rawRule?.id) ? rawRule.id.trim() : fallbackId, + label: isNonEmptyString(rawRule?.label) ? rawRule.label.trim() : "", + description: isNonEmptyString(rawRule?.description) + ? rawRule.description.trim() + : "", + enabled: rawRule?.enabled !== false, + match: { + exactUrl: isNonEmptyString(rawRule?.match?.exactUrl) + ? rawRule.match.exactUrl.trim() + : "", + urlRegex: isNonEmptyString(rawRule?.match?.urlRegex) + ? rawRule.match.urlRegex.trim() + : "", + excludeUrlRegexes: normalizeStringArray( + rawRule?.match?.excludeUrlRegexes + ).map((entry) => entry.trim()), + }, + redirect: { + url: isNonEmptyString(rawRule?.redirect?.url) + ? rawRule.redirect.url.trim() + : "", + replacement: isNonEmptyString(rawRule?.redirect?.replacement) + ? rawRule.redirect.replacement.trim() + : "", + }, + }; + + return rule; + } + + function isValidUrl(value) { + try { + new URL(value); + return true; + } catch { + return false; + } + } + + function isValidRegex(value) { + try { + new RegExp(value); + return true; + } catch { + return false; + } + } + + function validateMatch(rule) { + const errors = []; + + if (!(rule.match.exactUrl || rule.match.urlRegex)) { + errors.push( + `Rule "${rule.id}" needs either match.exactUrl or match.urlRegex.` + ); + } + + if (rule.match.exactUrl && !isValidUrl(rule.match.exactUrl)) { + errors.push(`Rule "${rule.id}" has an invalid match.exactUrl.`); + } + + if (rule.match.urlRegex && !isValidRegex(rule.match.urlRegex)) { + errors.push(`Rule "${rule.id}" has an invalid match.urlRegex.`); + } + + for (const excludePattern of rule.match.excludeUrlRegexes) { + if (!isValidRegex(excludePattern)) { + errors.push( + `Rule "${rule.id}" has an invalid exclude regex "${excludePattern}".` + ); + } + } + + return errors; + } + + function validateRedirect(rule) { + const errors = []; + + if (!(rule.redirect.url || rule.redirect.replacement)) { + errors.push( + `Rule "${rule.id}" needs either redirect.url or redirect.replacement.` + ); + } + + if (rule.redirect.url && !isValidUrl(rule.redirect.url)) { + errors.push(`Rule "${rule.id}" has an invalid redirect.url.`); + } + + return errors; + } + + function validateRule(rule, seenIds) { + const errors = []; + + if (seenIds.has(rule.id)) { + errors.push(`Duplicate rule id "${rule.id}".`); + } + seenIds.add(rule.id); + + return errors.concat(validateMatch(rule), validateRedirect(rule)); + } + + function validateRules(candidateRules) { + if (!Array.isArray(candidateRules)) { + return { + ok: false, + errors: ["Rules must be a JSON array."], + rules: [], + }; + } + + const rules = candidateRules.map(normalizeRule); + const seenIds = new Set(); + const errors = rules.flatMap((rule) => validateRule(rule, seenIds)); + + return { + ok: errors.length === 0, + errors, + rules, + }; + } + + function getDefaultRules() { + return deepClone(DEFAULT_RULES); + } + + async function getConfig() { + const stored = await storageGet({ + [STORAGE_KEYS.enabled]: true, + [STORAGE_KEYS.rules]: getDefaultRules(), + }); + + const validation = validateRules(stored[STORAGE_KEYS.rules]); + return { + enabled: stored[STORAGE_KEYS.enabled] !== false, + rules: validation.ok ? validation.rules : getDefaultRules(), + validationErrors: validation.ok ? [] : validation.errors, + }; + } + + async function saveConfig(config) { + const validation = validateRules(config.rules); + if (!validation.ok) { + return { + ok: false, + errors: validation.errors, + }; + } + + await storageSet({ + [STORAGE_KEYS.enabled]: config.enabled !== false, + [STORAGE_KEYS.rules]: validation.rules, + }); + + return { + ok: true, + errors: [], + }; + } + + function expandReplacement(template, match) { + return template.replace( + /\$(\d+)/g, + (_, index) => match[Number(index)] ?? "" + ); + } + + function isRuleExcluded(urlString, rule) { + return rule.match.excludeUrlRegexes.some((excludePattern) => + new RegExp(excludePattern).test(urlString) + ); + } + + function buildRedirectResult(rule, targetUrl, urlString) { + if (!targetUrl || targetUrl === urlString) { + return null; + } + + return { + rule, + targetUrl, + }; + } + + function matchExactRedirect(urlString, rule) { + if (!(rule.match.exactUrl && urlString === rule.match.exactUrl)) { + return null; + } + + return buildRedirectResult(rule, rule.redirect.url, urlString); + } + + function matchRegexRedirect(urlString, rule) { + if (!rule.match.urlRegex) { + return null; + } + + const regex = new RegExp(rule.match.urlRegex); + const match = urlString.match(regex); + if (!match) { + return null; + } + + const targetUrl = + rule.redirect.url || expandReplacement(rule.redirect.replacement, match); + return buildRedirectResult(rule, targetUrl, urlString); + } + + function findRedirectForRule(urlString, rawRule) { + const rule = normalizeRule(rawRule, 0); + if (!rule.enabled || isRuleExcluded(urlString, rule)) { + return null; + } + + return ( + matchExactRedirect(urlString, rule) || matchRegexRedirect(urlString, rule) + ); + } + + function findRedirect(urlString, rules) { + for (const rawRule of rules) { + const redirect = findRedirectForRule(urlString, rawRule); + if (redirect) { + return redirect; + } + } + + return null; + } + + global.DiffKitRedirect = { + STORAGE_KEYS, + getConfig, + getDefaultRules, + saveConfig, + validateRules, + findRedirect, + }; +})(globalThis); diff --git a/extensions/diffkit-redirect/styles.css b/extensions/diffkit-redirect/styles.css new file mode 100644 index 0000000..8d93010 --- /dev/null +++ b/extensions/diffkit-redirect/styles.css @@ -0,0 +1,425 @@ +:root { + --background: #ffffff; + --foreground: oklch(0.141 0.005 285.823); + --card: #ffffff; + --card-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.21 0.006 285.885); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.871 0.006 286.286); + --brand: oklch(0.68 0.2 150); + --shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.06); + --shadow-md: 0 12px 32px rgba(16, 24, 40, 0.08); + font-family: + "Inter Variable", "Inter", "Avenir Next", ui-sans-serif, system-ui, + sans-serif; + color: var(--foreground); + color-scheme: light; + background: var(--background); +} + +* { + box-sizing: border-box; +} + +body { + min-width: 360px; + margin: 0; + font-size: 13px; + font-weight: 450; + color: var(--foreground); + background: + radial-gradient( + circle at top right, + color-mix(in srgb, var(--brand) 12%, transparent), + transparent 30% + ), + linear-gradient(180deg, #ffffff 0%, #fafafa 100%); +} + +button, +textarea, +input { + font: inherit; +} + +button { + padding: 0.625rem 0.875rem; + font-size: 13px; + font-weight: 500; + color: var(--primary-foreground); + letter-spacing: -0.01em; + cursor: pointer; + background: var(--primary); + border: 1px solid transparent; + border-radius: 10px; + box-shadow: var(--shadow-sm); + transition: + background-color 120ms ease, + border-color 120ms ease, + box-shadow 120ms ease; +} + +button.secondary { + color: var(--secondary-foreground); + background: var(--secondary); + border-color: var(--border); +} + +button:hover { + background: color-mix(in srgb, var(--primary) 92%, white); +} + +button.secondary:hover { + background: color-mix(in srgb, var(--secondary) 88%, white); +} + +button:focus-visible, +textarea:focus-visible, +input:focus-visible { + outline: none; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--ring) 55%, transparent); +} + +code, +pre { + font-family: + "SFMono-Regular", ui-monospace, "Cascadia Code", "Source Code Pro", + monospace; +} + +.popup-body { + --background: #141518; + --foreground: oklch(0.965 0.003 286); + --card: #16171a; + --card-foreground: oklch(0.965 0.003 286); + --primary: oklch(0.965 0.003 286); + --primary-foreground: #121316; + --secondary: #202227; + --secondary-foreground: oklch(0.86 0.01 286); + --muted: #1a1c20; + --muted-foreground: oklch(0.7 0.012 286); + --border: rgba(255, 255, 255, 0.08); + --input: #2b2e34; + --ring: rgba(255, 255, 255, 0.22); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35); + --shadow-md: 0 16px 40px rgba(0, 0, 0, 0.42); + min-width: 400px; + color-scheme: dark; + background: #141518; +} + +.popup-shell { + padding: 0; +} + +.popup-card, +.options-panel, +.help-panel { + background: color-mix(in srgb, var(--card) 96%, transparent); + border: 1px solid var(--border); + border-radius: 18px; + box-shadow: var(--shadow-md); +} + +.popup-card { + padding: 12px; + background: transparent; + border: 0; + border-radius: 0; + box-shadow: none; +} + +.brand-row, +.options-header, +.toggle-row, +.panel-actions, +.status-row { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; +} + +.panel-actions { + margin-top: 16px; +} + +.eyebrow { + margin: 0 0 4px; + font-family: + "Geist Mono Variable", "SF Mono", ui-monospace, "Cascadia Code", monospace; + font-size: 11px; + font-weight: 500; + color: var(--muted-foreground); + letter-spacing: 0.06em; +} + +h1, +h2, +p { + margin: 0; +} + +h1 { + font-size: 18px; + font-weight: 600; + letter-spacing: -0.02em; +} + +h2 { + margin-bottom: 10px; + font-size: 15px; + font-weight: 600; + letter-spacing: -0.02em; +} + +.muted, +.lede { + margin-top: 12px; + line-height: 1.55; + color: var(--muted-foreground); +} + +.lede { + max-width: 34ch; + text-wrap: pretty; +} + +.rule-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px; + margin-top: 12px; +} + +.empty-state { + grid-column: 1 / -1; + padding: 12px; + margin: 0; + line-height: 1.5; + color: var(--muted-foreground); + border: 1px dashed var(--border); + border-radius: 14px; +} + +.rule-toggle { + display: flex; + gap: 6px; + align-items: flex-start; + width: 100%; + min-height: 44px; + padding: 7px 8px; + color: var(--muted-foreground); + text-align: left; + background: var(--secondary); + border: 1px solid transparent; + border-radius: 12px; + box-shadow: none; + transition: + background-color 120ms ease, + color 120ms ease, + border-color 120ms ease, + transform 120ms ease; +} + +.rule-toggle:hover { + color: var(--foreground); + background: color-mix(in srgb, var(--secondary) 92%, white 4%); +} + +.rule-toggle[aria-pressed="true"] { + color: #ffffff; + background: color-mix(in srgb, var(--foreground) 14%, #0f1012); +} + +.rule-copy { + position: relative; + display: grid; + gap: 1px; + width: 100%; + min-width: 0; + padding-left: 28px; +} + +.rule-copy::before { + position: absolute; + top: 0.0625rem; + left: 0; + width: 20px; + height: 20px; + content: ""; + background: rgba(255, 255, 255, 0.08); + border-radius: 999px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06); +} + +.rule-copy::after { + position: absolute; + top: 0.4375rem; + left: 7px; + width: 6px; + height: 6px; + content: ""; + background: currentColor; + border-radius: 999px; + opacity: 0.8; +} + +.rule-toggle[aria-pressed="true"] .rule-copy::before { + background: rgba(255, 255, 255, 0.12); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); +} + +.rule-title { + font-size: 12px; + font-weight: 600; + line-height: 1.2; + letter-spacing: -0.01em; +} + +.rule-description { + font-family: + "Geist Mono Variable", "SF Mono", ui-monospace, "Cascadia Code", monospace; + font-size: 10px; + letter-spacing: 0.04em; + opacity: 0.72; +} + +.rule-toggle[aria-pressed="true"] .rule-description { + color: rgba(255, 255, 255, 0.72); +} + +.brand-lockup { + display: flex; + gap: 12px; + align-items: center; +} + +.brand-logo { + flex: none; + width: 42px; + height: 42px; + border-radius: 12px; + box-shadow: var(--shadow-sm); +} + +.popup-body button.secondary { + color: var(--foreground); + background: #1c1e23; + border-color: rgba(255, 255, 255, 0.08); +} + +.popup-body button.secondary:hover { + background: #24272d; +} + +.switch { + position: relative; + display: inline-flex; +} + +.switch input { + position: absolute; + inset: 0; + opacity: 0; +} + +.switch-track { + position: relative; + width: 36px; + height: 20px; + background: var(--input); + border-radius: 999px; + box-shadow: inset 0 0 0 1px var(--border); + transition: background 120ms ease; +} + +.switch-track::after { + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + content: ""; + background: var(--background); + border-radius: 50%; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18); + transition: transform 120ms ease; +} + +.switch input:checked + .switch-track { + background: var(--primary); + box-shadow: none; +} + +.switch input:checked + .switch-track::after { + transform: translateX(16px); +} + +.status-row { + padding-top: 14px; + margin-top: 16px; + border-top: 1px solid color-mix(in srgb, var(--border) 85%, transparent); +} + +.options-layout { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr); + gap: 18px; + align-items: start; + padding: 24px; +} + +.options-panel, +.help-panel { + padding: 20px; +} + +.rules-editor { + width: 100%; + min-height: 420px; + padding: 16px; + margin-top: 18px; + line-height: 1.5; + color: #f5f5f5; + resize: vertical; + background: #101011; + border: 1px solid var(--border); + border-radius: 16px; +} + +.code-block { + padding: 16px; + margin: 0; + overflow: auto; + line-height: 1.5; + color: #f5f5f5; + background: #101011; + border-radius: 16px; +} + +.status-copy { + min-height: 20px; + line-height: 1.45; + color: var(--muted-foreground); +} + +.status-copy[data-tone="error"] { + color: oklch(0.577 0.245 27.325); +} + +.status-copy[data-tone="success"] { + color: color-mix(in srgb, var(--brand) 60%, black); +} + +@media (max-width: 900px) { + .options-layout { + grid-template-columns: 1fr; + } +}