import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons'; import * as Clipboard from 'expo-clipboard'; import * as IntentLauncher from 'expo-intent-launcher'; import * as Linking from 'expo-linking'; import { StatusBar } from 'expo-status-bar'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Alert, Animated, AppState, BackHandler, Image, Keyboard, KeyboardAvoidingView, Modal, Platform, Pressable, ScrollView, Share, StyleSheet, Text, TextInput, ToastAndroid, View, Easing, useWindowDimensions, } from 'react-native'; import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'; import { BROWSERS, explainPrivacy, type Browser, type PrivacyMode, } from '@/constants/browsers'; import { PRIVACY_TEST_DATE } from '@/constants/privacy-scores'; import { privacyBreakdown, privacyScore, securityBreakdown, securityScore, } from '@/constants/ratings'; import { securityTips, privacyTips } from '@/constants/optimization'; import { glossaryFor } from '@/constants/glossary'; import { BUILD_COMMIT, BUILD_VERSION } from '@/constants/build-info'; import { EXTRA_SUGGESTIONS, INCOGNITO_KEYS, MODE_MANAGED_KEYS, PROFILES, PROFILE_LABEL, deriveModeFromExtras, defaultsForProfile, extrasForMode, modesForProfile, REFERRER_EXTRA_KEY, isMaxPrivacy, loadFlows, lookupSuggestion, newFlowId, saveFlows, suggestionsFor, syncExtrasWithMode, type ExtraSuggestion, type Flow, type FlowExtra, type Profile, } from '@/constants/flows'; import { Fonts, Palette } from '@/constants/theme'; import { SITE_TRACKING_RULES, TRACKING_PARAMS, TRACKING_PREFIXES, stripTrackingParams, } from '@/constants/tracking-params'; import { fullyUnwrapRedirect } from '@/constants/redirect-unwrap'; import { checkLinkLeaks, leakSummary } from '@/constants/link-leak'; import { DEFAULT_PROXY_CONFIG, PROXY_DESTINATIONS, PROXY_PREF_KEY, allInstancesFor, enabledInstanceCount, findDestinationForUrl, loadProxyConfig, newCustomDestId, normalizeProxyHost, rewriteThroughProxy, type CustomDest, type ProxyConfig, type ProxyDestination, } from '@/constants/proxies'; import { formatRunicName } from '@/constants/runes'; import { getBoolPref, getBrowserIcon, getInitialSharedText, getInitialViewUrl, getInstalledBrowsers, getStringPref, isDefaultBrowser, type LaunchMode, markSharedTextConsumed, markViewUrlConsumed, onIncomingUrl, onSharedTextChanged, launchPackage, openInBrowser, setBoolPref, setStringPref, } from '@/modules/default-browser'; // Single source of truth for the app's display name. Read at bundle time // from app.json via expo-constants so renaming the app means editing // exactly one field (expo.name). Defaults are conservative fallbacks for // the unlikely case Constants isn't populated yet. // In-app display name. Hardcoded (not read from expo-constants) so JS // reloads pick up rebrands without a prebuild. App ID stays // `org.vikingware.webwarden` (legacy) so existing installs receive // updates — the display name "Warden" is decoupled. const APP_NAME: string = 'Warden'; /** * Whether browser display names should be transliterated into * Elder Futhark runes. Provided by Home so every nested component * (BrowserPicker, FlowEditorModal, action sheets, encyclopedia, …) * can read the current toggle without prop-drilling. */ const RunicNamesContext = React.createContext(false); // Split the name into a "neutral head" + "accent tail" for the styled // wordmark in the header. The split is purely visual; if the name has at // least 4 characters the tail is the last 3 letters, otherwise the whole // name renders neutral. /** * Animated wordmark — letters of APP_NAME tinted from a looping * palette gradient. A single Animated.Value drives the cycle (0 → 1 * every 3s); each letter offsets its phase by its position in the * word, so the colors visibly "flow" left-to-right through the * wordmark like a glow running across an inlay. * * useNativeDriver is forced to `false` because the native driver * doesn't animate `color` — fine here, the cost is negligible for * 7 letters and we're not animating layout. */ /** * Quartered shield — outer ring, horizontal + vertical cross, two * opposite quadrants tinted, filled central boss. From shield #02 * in the variations sketch. */ function QuarteredShieldIcon({ size = 18, color = Palette.textMuted }: { size?: number; color?: string }) { // Force even thickness so all derived offsets stay on integer // pixels — Android fuzzes sub-pixel positions and the icon // visibly drifts off-axis without this. const thickness = 2; // Half-size = whole pixel (since size is even). Center coords clean. const half = size / 2; const bossPx = Math.max(2, Math.round(size * 0.12)); // Lines start one thickness inside the rim so they don't fight // the borderRadius mask at the corners. const lineInset = thickness; const lineLen = size - thickness * 2; return ( {/* Tinted quarters — top-left + bottom-right */} {/* Cross — clean integer positions */} ); } /** * Tiny line drawn from (x1,y1) to (x2,y2) in a 100×100 coordinate * space, used by the shield-derived icon components below so they * stay readable at small sizes without pulling in react-native-svg. * Coords are relative to a -50..50 viewBox-style origin. */ function IconLine({ size, color, thickness, from, to, }: { size: number; color: string; thickness: number; from: [number, number]; to: [number, number]; }) { const [x1, y1] = from; const [x2, y2] = to; // Translate viewBox-style coords (-50..50, origin centered) into // pixel coords (0..size, origin top-left). const px1 = ((x1 + 50) * size) / 100; const py1 = ((y1 + 50) * size) / 100; const px2 = ((x2 + 50) * size) / 100; const py2 = ((y2 + 50) * size) / 100; const dx = px2 - px1; const dy = py2 - py1; const length = Math.sqrt(dx * dx + dy * dy); const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI; const cx = (px1 + px2) / 2; const cy = (py1 + py2) / 2; return ( ); } /** * Yggdrasil hint — vertical trunk + three pairs of upward branches * + two roots, all inside an outer ring. Derived from shield #19 in * the variations sketch. */ function YggdrasilIcon({ size = 16, color = Palette.textMuted }: { size?: number; color?: string }) { const thickness = Math.max(1, Math.round(size / 14)); const lines: [[number, number], [number, number]][] = [ // Trunk [[0, 34], [0, -32]], // Top branches [[0, -32], [-14, -22]], [[0, -32], [14, -22]], // Mid branches [[0, -18], [-18, -6]], [[0, -18], [18, -6]], // Lower branches [[0, -4], [-22, 10]], [[0, -4], [22, 10]], // Roots [[0, 34], [-12, 40]], [[0, 34], [12, 40]], ]; return ( {lines.map((seg, i) => ( ))} ); } /** * Studded-rim shield icon — outer ring with twelve rivets around * the perimeter and a filled central boss. From shield #18 in the * variations sketch. */ function StuddedRimIcon({ size = 16, color = Palette.textMuted }: { size?: number; color?: string }) { const thickness = Math.max(1, Math.round(size / 14)); // Stud positions on a circle of radius 40 (in -50..50 viewBox), // 12 stops evenly spaced (every 30°). Generated rather than // hand-listed so the math stays explicit. const studs = Array.from({ length: 12 }, (_, i) => { const angle = (i * 30 - 90) * (Math.PI / 180); return [Math.cos(angle) * 40, Math.sin(angle) * 40] as [number, number]; }); const studPx = Math.max(2, Math.round(size * 0.12)); const bossPx = Math.max(3, Math.round(size * 0.25)); return ( {studs.map(([sx, sy], i) => { const left = ((sx + 50) * size) / 100 - studPx / 2; const top = ((sy + 50) * size) / 100 - studPx / 2; return ( ); })} ); } /** * Sun-disk / Sólarhjul icon — the viking 12-spoke wheel from the * shield variations sketch. Built from primitive Views so we don't * have to add react-native-svg: outer circle, central boss, six * lines crossing the center (each line = two opposite spokes for * a total of 12). Pure vector look, scales cleanly via `size`. */ function SundiskIcon({ size = 16, color = Palette.textMuted }: { size?: number; color?: string }) { // 6 line angles spanning 0–150° give 12 spokes (each line is // diametric, covering two opposite spokes). const spokeAngles = [0, 30, 60, 90, 120, 150]; const spokeThickness = Math.max(1, Math.round(size / 16)); const bossSize = Math.max(2, Math.round(size / 4)); return ( {spokeAngles.map((angle) => ( ))} ); } function WardenWordmark({ style }: { style?: any }) { const t = useRef(new Animated.Value(0)).current; useEffect(() => { // One full sweep across the wordmark (t: 0 → 2 over 4s), a 2s // rest with t held at 2 (every letter clamped past the palette // end → all deep), then a snap reset back to 0 and loop. Total // cycle 6s with a clear "pause between waves" beat. const sweep = Animated.timing(t, { toValue: 2, duration: 4000, easing: Easing.linear, useNativeDriver: false, }); const rest = Animated.delay(2000); const reset = Animated.timing(t, { toValue: 0, duration: 0, useNativeDriver: false, }); const loop = Animated.loop(Animated.sequence([sweep, rest, reset])); loop.start(); return () => loop.stop(); }, [t]); // Forest-only palette. Endpoints set the base (rest) color — // bumped from accentDeep to accent so during the 2s pause the // wordmark reads as a readable mossy green instead of dropping // to near-black. The sweep itself still climbs through bright // → highlight → bright back down. const palette = useMemo(() => [ Palette.accent, Palette.accentBright, Palette.accentBright, Palette.highlight, Palette.accentBright, Palette.accentBright, Palette.accent, ], []); const inputRange = useMemo( () => palette.map((_, i) => i / (palette.length - 1)), [palette], ); const letters = useMemo(() => APP_NAME.split(''), []); // Flat row of standalone Animated.Texts — nesting Animated.Text // inside a parent doesn't propagate color animations // reliably on Android, so we lay the letters out as a flex row // with baseline alignment. Each letter inherits the brandName // font from the `style` prop. return ( {letters.map((char, i) => { // Phase shift per letter. Subtraction (vs add) makes the // peak travel left → right as t increases. No modulo — // shifted just grows past 1 at the end of the sweep, and // the interpolate clamps it back to "deep" so the rest // period after t=2 reads as one calm forest tone. const phase = i / letters.length; const shifted = Animated.subtract(t, phase); const color = shifted.interpolate({ inputRange, outputRange: palette, extrapolate: 'clamp', }); // Glow halo dropped — read as a bright "white" pulse before, // and we want the wordmark to feel organic/smooth, not a // torch sweep. The color sweep alone carries the motion. return ( {char} ); })} ); } /** * Bundled launcher icons by browser id. Resolved at bundle time so we don't * need INTERNET permission, and they render the same whether the browser is * installed locally or not. Anything missing here falls back first to the * installed app's launcher icon (PackageManager → base64) and then to the * solid color swatch. */ const ASSET_ICONS: Record = { brave: require('./assets/browser-icons/brave.png'), chrome: require('./assets/browser-icons/chrome.png'), ddg: require('./assets/browser-icons/ddg.png'), firefox: require('./assets/browser-icons/firefox.png'), 'firefox-beta': require('./assets/browser-icons/firefox-beta.png'), felice: require('./assets/browser-icons/felice.png'), 'firefox-nightly': require('./assets/browser-icons/firefox-nightly.png'), focus: require('./assets/browser-icons/focus.png'), ironfox: require('./assets/browser-icons/ironfox.png'), tor: require('./assets/browser-icons/tor.png'), vanadium: require('./assets/browser-icons/vanadium.png'), }; /** * Curated one-line endorsements rendered as a small pill under the * browser name in the picker. Keep this list short and opinionated — * a pill on every row defeats the point. */ const BROWSER_RECOMMENDATION: Record = { brave: '(best daily)', }; /** * Which score icons (if any) to render in gold + filled for a given * browser, marking it as "best-in-class" on that axis. Matched to the * BROWSER_RECOMMENDATION labels: Vanadium tops Security, IronFox tops * Privacy, Brave is the all-rounder recommendation so both halves * glow. Kept as a small table so it's easy to retune. */ const SCORE_ACCENT: Record = { brave: { security: true, privacy: true }, vanadium: { security: true }, tor: { privacy: true }, ironfox: { privacy: true }, }; /** * Browsers that support third-party extensions / add-ons on Android. * Surfaced as a small puzzle icon on the picker row + a matching * encyclopedia bullet — lets users know they can layer uBlock Origin, * password managers, or other tooling on top. */ const BROWSER_HAS_EXTENSIONS: Record = { ironfox: true, firefox: true, 'firefox-beta': true, 'firefox-nightly':true, helium: true, // Tor Browser on Android (Fenix-based) supports a curated set of // extensions; the Tor Project advises against installing them // because they can de-anonymise — but the capability exists and // some users layer NoScript or password managers anyway. tor: true, // Firefox Focus also runs on the Fenix runtime — modern builds // support extensions even though the headline pitch is // always-private + no-history. focus: true, }; // Warm gold used for the score accents and the autolaunch flash badge. const GOLD = '#f6c84c'; /** * Availability caveats surfaced as a prominent info pill at the top of * the browser's encyclopedia modal. Use for browsers that can't be * installed in the usual way — e.g. only ship on a specific OS or * distribution. Keep the text short and parenthesised. */ type AvailabilityNote = { /** Long phrasing for the encyclopedia info pill. */ long: string; /** Compact phrasing for the picker row's not-installed line. */ short: string; }; const BROWSER_AVAILABILITY: Record = { vanadium: { long: 'Available only for GrapheneOS', short: 'only GrapheneOS' }, helium: { long: 'Available only for GrapheneOS', short: 'only GrapheneOS' }, }; function modeToLaunch(b: Browser, m: PrivacyMode): LaunchMode | null { // Always-private browsers (Tor, DDG): every visit is private regardless, // so we use the simplest intent shape and let the browser do its thing. if (b.alwaysPrivate) return 'normal'; switch (m) { case 'normal': return 'normal'; case 'mini': return 'cct-normal'; case 'private': return b.privateMode === 'chromium-class' ? 'chromium-class' : b.privateMode === 'chromium-extra' ? 'chromium-extra' : b.privateMode === 'firefox' ? 'firefox-private' : null; case 'max': return 'cct-private'; } } // Empty default — paste, share-intake, or a Browser-check preset // populates it. A bare hostname typed by the user is accepted; the // launch path normalises by prepending `https://` (see normalizeUrl). const DEFAULT_URL = ''; // Window inside which the same incoming URL is treated as a duplicate // (one of the redundant native + JS intake pipes re-delivering). Past // this, the same URL can re-fire — e.g., the user back-gestures from // the browser and shares the same link again. const REINTAKE_WINDOW_MS = 1500; // SharedPreferences key for the strip-tracking toggle. Defaults to on. const PREF_STRIP_TRACKING = 'strip_tracking'; const PREF_RUNIC_NAMES = 'runic_names'; const PREF_SHOW_PASTE = 'show_paste'; const PREF_SHOW_CLEAR = 'show_clear'; // SharedPreferences key for the user's custom-preset URLs — a JSON // array of strings rendered inline at the bottom of the Presets modal. const PREF_CUSTOM_PRESETS = 'custom_presets'; function loadCustomPresets(raw: string): string[] { try { const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return []; return parsed.filter((x): x is string => typeof x === 'string' && x.length > 0); } catch { return []; } } /** * Sentinel value stored in the URL field that means "this isn't a real URL — * each Flow should launch its browser's own internal config / flags page". * Selected from the Presets dropdown. Detected at launch time so the * browser's `configUrl` is substituted in just before dispatch. */ const BROWSER_CONFIG_SENTINEL = 'warden:browser-config'; type PresetIcon = keyof typeof Ionicons.glyphMap; type PresetItem = { label: string; url: string; hint: string; icon: PresetIcon }; type PresetGroup = { id: string; label: string; items: PresetItem[] }; /** * User-facing placeholder for the per-browser config URL. Sits in the * URL field as the literal string `@config`; at launch time we * substitute the chosen Flow's browser-specific `configUrl` (e.g. * `chrome://flags`, `about:config`) and route through the share path * because Android firewalls internal schemes from ACTION_VIEW. * * Kept as a single token so the UI stays generic ("Browser flags" * always maps to "whatever flags page this browser exposes") and we * can swap in a different per-browser URL later without changing the * preset table or any settings. */ const CONFIG_PLACEHOLDER = '@browser.flags'; /** * Grouped link presets surfaced in the Presets modal. Each group is * rendered as a small caps header followed by its items. Add a new * group object here — no rendering changes required. */ const PRESET_GROUPS: PresetGroup[] = [ { id: 'browser-config-and-checks', label: 'Browser Config & Checks', items: [ { label: 'Browser flags', url: CONFIG_PLACEHOLDER, hint: 'Each Flow opens its own flags page (chrome://flags, about:config, …)', icon: 'flask-outline', }, { label: 'Incognito check', url: 'https://detectincognito.com', hint: 'Tries to detect private mode', icon: 'eye-off-outline' }, { label: 'IP & headers', url: 'https://browserleaks.com/ip', hint: 'Outbound IP + request headers', icon: 'globe-outline' }, { label: 'JS & cookies', url: 'https://browserleaks.com/javascript', hint: 'document.cookie + runtime info', icon: 'cafe-outline' }, { label: 'Client hints / UA', url: 'https://browserleaks.com/client-hints', hint: 'User-Agent + Sec-CH-UA headers', icon: 'finger-print-outline' }, { label: 'Canvas fingerprint', url: 'https://browserleaks.com/canvas', hint: 'Canvas hash — differs across isolated sessions', icon: 'brush-outline' }, { label: 'WebGL fingerprint', url: 'https://browserleaks.com/webgl', hint: 'GPU/driver hash', icon: 'cube-outline' }, ], }, ]; /** * Schemes recognised as "internal flag links" — they can't be opened * via Intent.ACTION_VIEW from a third-party app (the OS firewalls * them) so the launch path falls back to a Share intent. Conservative * allow-list so we never treat a `javascript:` or `file:` URL as a * flag link. */ const FLAG_SCHEMES: ReadonlySet = new Set([ 'chrome', 'chromium', 'brave', 'edge', 'vivaldi', 'opera', 'kiwi', 'about', // Firefox / Gecko 'firefox', // Some Fenix internal pages ]); function isFlagLinkUrl(s: string | null | undefined): boolean { if (!s) return false; const m = s.trim().match(/^([a-z]+):/i); if (!m) return false; return FLAG_SCHEMES.has(m[1].toLowerCase()); } function extractUrlFromText(text: string | null | undefined): string | null { if (!text) return null; // Prefer an explicit http(s):// inside the text — handles shares like // "Cool article: https://example.com/foo" where there's prose around // the link. const match = text.match(/https?:\/\/[^\s]+/i); if (match) return match[0]; // Fallback: the whole trimmed text might be a bare hostname (e.g. // "google.com" or "192.168.0.1/admin"). normalizeUrl validates and // prefixes https:// when it looks URL-shaped; returns null otherwise. return normalizeUrl(text); } function isHttpUrl(u: string | null | undefined): u is string { return !!u && /^https?:\/\//i.test(u); } /** * Normalise whatever the user typed in the URL field to a launchable * URL — or `null` if it isn't one. Accepts: * - already-prefixed `http://…` / `https://…` * - bare hostname like `google.com`, `sub.example.org/path`, * `192.168.0.1`, `localhost:3000` — `https://` is prepended. * Trailing whitespace is trimmed. Anything else (single words without a * dot, free text, etc.) returns null so the launch path stays a no-op. */ /** * Drop the trailing "/" added by `URL.toString()` when the pathname * is just "/" and there's no query or hash. HTTP-equivalent to keeping * it, but mirrors the structure of input that didn't have one — so * `example.com` doesn't display as `https://example.com/`. * * URLs with a non-root path keep their trailing slash, since on some * servers it's load-bearing (directory redirects, ETag salts, …). */ function stripBareRootSlash(url: string): string { try { const u = new URL(url); if (u.pathname === '/' && !u.search && !u.hash) { return `${u.protocol}//${u.host}`; } return url; } catch { return url; } } function normalizeUrl(u: string | null | undefined): string | null { if (!u) return null; const s = u.trim(); if (!s) return null; if (/^https?:\/\/.+/i.test(s)) return s; // Hostname-ish input — bare host, optional port, then any combination // of path / query / hash that a real URL can carry. We anchor the // host pattern and let `[/?#].*` swallow the rest in one go, so // strings like `example.com?x=1`, `example.com#sec`, and the older // `example.com/path?x=1#sec` all parse without the path being // required first. Three host shapes: dotted hostname, localhost, // bare IPv4. const tail = '(?::\\d+)?(?:[/?#].*)?'; if (new RegExp(`^[a-z0-9][a-z0-9-]*(?:\\.[a-z0-9-]+)+${tail}$`, 'i').test(s)) { return `https://${s}`; } if (new RegExp(`^localhost${tail}$`, 'i').test(s)) return `https://${s}`; if (new RegExp(`^\\d{1,3}(?:\\.\\d{1,3}){3}${tail}$`).test(s)) return `https://${s}`; return null; } function Home() { const insets = useSafeAreaInsets(); const [target, setTarget] = useState(DEFAULT_URL); // Ref mirror of target so handleOpenFlow always reads the latest URL // even when invoked from a stale closure (e.g. a Flow's onPress that // was captured before a rapid target change). const targetRef = useRef(target); useEffect(() => { targetRef.current = target; }, [target]); const [isExternal, setIsExternal] = useState(false); const [installed, setInstalled] = useState>(new Set()); // Last successful installed set. Used as fallback when the native module // throws — we keep the previous value instead of showing every browser // as installed (which would let the user attempt a launch that fails). const installedRef = useRef(installed); useEffect(() => { installedRef.current = installed; }, [installed]); const [isDefault, setIsDefault] = useState(false); const [errorMsg, setErrorMsg] = useState(null); const [showPresets, setShowPresets] = useState(false); const [icons, setIcons] = useState>({}); // User-defined launch Flows. Initialised synchronously; re-loaded once // after mount to dodge the cold-start race where the native module // briefly returns the default. const [flows, _setFlows] = useState(() => loadFlows()); const persistFlows = (next: Flow[]) => { _setFlows(next); saveFlows(next); }; // The flow currently being edited (or freshly created). null = modal closed. const [editingFlow, setEditingFlow] = useState(null); // Long-press action sheet target. null = sheet closed. const [actionFlow, setActionFlow] = useState(null); // Long-press target for the right-side (launch) of a Flow row. Opens a // small "how do you want to open it?" picker — separate from the full // action sheet, which is long-press on the LEFT (edit) side. const [openOptionsFlow, setOpenOptionsFlow] = useState(null); // First step of "New Flow" — pick a browser before the editor opens. const [newFlowPickerOpen, setNewFlowPickerOpen] = useState(false); // Active profile tab for filtering the flow list const [activeProfile, setActiveProfile] = useState('privacy'); const [aboutOpen, setAboutOpen] = useState(false); const [donateOpen, setDonateOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); // Strip-tracking preference, persisted via the native module's // SharedPreferences shim. Defaults to true — matches Brave's // default and keeps the privacy-by-default posture. const [stripTracking, setStripTracking] = useState(() => getBoolPref(PREF_STRIP_TRACKING, true), ); const updateStripTracking = (next: boolean) => { setStripTracking(next); setBoolPref(PREF_STRIP_TRACKING, next); }; // Ref read by intake handlers so the latest pref value is used // without re-creating the listener closures on every toggle. const stripTrackingRef = useRef(stripTracking); useEffect(() => { stripTrackingRef.current = stripTracking; }, [stripTracking]); // Runic browser names — when on, every browser name renders as // Elder Futhark runes (Brave → ᛒᚱᚨᚹᛖ, Firefox → ᚠᛁᚱᛖᚠᛟᚲᛋ…). // Pure display flourish, off by default. Underlying data, error // strings, and accessibility labels stay in Latin so screen // readers and copy-paste continue to work. const [runicNames, setRunicNames] = useState(() => getBoolPref(PREF_RUNIC_NAMES, false), ); const updateRunicNames = (next: boolean) => { setRunicNames(next); setBoolPref(PREF_RUNIC_NAMES, next); }; // Optional Paste / Clear pills in the SOURCE row. Off by default — // most users don't need them, the keyboard + system paste menu // already covers it. Power users can opt in via Settings → UI. const [showPaste, setShowPaste] = useState(() => getBoolPref(PREF_SHOW_PASTE, false), ); const updateShowPaste = (next: boolean) => { setShowPaste(next); setBoolPref(PREF_SHOW_PASTE, next); }; const [showClear, setShowClear] = useState(() => getBoolPref(PREF_SHOW_CLEAR, false), ); const updateShowClear = (next: boolean) => { setShowClear(next); setBoolPref(PREF_SHOW_CLEAR, next); }; // Privacy-proxy redirect config (Nitter / Invidious / …). Persisted // as a single JSON blob in SharedPreferences so the schema can // evolve without adding new keys. Default = disabled. const [proxyConfig, setProxyConfigState] = useState(() => loadProxyConfig(getStringPref(PROXY_PREF_KEY, '')), ); // User-defined preset URLs, surfaced at the bottom of the Presets // modal under a "Custom" header. Stored as a JSON array of strings. const [customPresets, setCustomPresetsState] = useState(() => loadCustomPresets(getStringPref(PREF_CUSTOM_PRESETS, '')), ); const updateCustomPresets = (next: string[]) => { setCustomPresetsState(next); setStringPref(PREF_CUSTOM_PRESETS, JSON.stringify(next)); }; const proxyConfigRef = useRef(proxyConfig); useEffect(() => { proxyConfigRef.current = proxyConfig; }, [proxyConfig]); const updateProxyConfig = (next: ProxyConfig) => { setProxyConfigState(next); setStringPref(PROXY_PREF_KEY, JSON.stringify(next)); }; // Cycle index for the proxy rewrite. Walks forward through the // enabled-instance rotation deterministically — the cycle button // on the URL status row bumps this. When the matched destination // changes (or its randomise pref flips), an effect below resets // the index: 0 when randomise is off, a random seed when on. const [proxyCycleIndex, setProxyCycleIndex] = useState(0); // Quick-edit target — set by long-pressing the launch URL when the // link is proxied. Opens the per-destination settings shortcut so // the user can toggle instances without drilling through Settings. const [quickProxyDestId, setQuickProxyDestId] = useState(null); // Single source of truth for "the URL Warden will actually launch": // normalize → unwrap → strip → rewrite, computed once per change so the // status preview and handleOpenFlow agree on the exact URL (and pick // the same proxy instance every time for a given cycle index). const launchPreview = useMemo(() => { const trimmed = target.trim(); if (!trimmed) return null; if ( target === BROWSER_CONFIG_SENTINEL || target === CONFIG_PLACEHOLDER || isFlagLinkUrl(target) ) return null; const normalised = normalizeUrl(target); if (!normalised) return null; const afterUnwrap = fullyUnwrapRedirect(normalised); const afterStrip = stripTracking ? stripTrackingParams(afterUnwrap.url) : { url: afterUnwrap.url, stripped: [] as string[] }; const afterProxy = rewriteThroughProxy(afterStrip.url, proxyConfig, proxyCycleIndex); return { url: stripBareRootSlash(afterProxy.url), stripped: afterStrip.stripped, via: afterProxy.via, unwrappedHops: afterUnwrap.hops, matchedDest: findDestinationForUrl(normalised, proxyConfig), }; }, [target, stripTracking, proxyConfig, proxyCycleIndex]); // Mirror for handleOpenFlow — closure-stable, always reads latest. const launchPreviewRef = useRef(launchPreview); useEffect(() => { launchPreviewRef.current = launchPreview; }, [launchPreview]); // Reset the cycle index whenever the matched destination identity // (or its randomise pref) changes. With randomise on we seed a // random starting index so first-view is a fresh pick; otherwise // we anchor to 0 so the user always sees the first enabled // instance until they tap cycle. const matchedDestId = launchPreview?.matchedDest?.id ?? null; useEffect(() => { if (!matchedDestId) { setProxyCycleIndex(0); return; } const dest = launchPreview?.matchedDest; if (!dest || dest.id !== matchedDestId) return; // Auto-randomise the starting pick whenever multiple instances // are enabled. With a single enabled instance there's nothing to // randomise — anchor to 0. No user-facing toggle: the rule is // simply "multiple enabled ⇒ randomised first pick, then the // cycle button steps forward." const n = enabledInstanceCount(dest, proxyConfig); setProxyCycleIndex(n > 1 ? Math.floor(Math.random() * n) : 0); // Re-seed on destination identity change only — toggling an // instance off shouldn't yank the user to a different proxy. // eslint-disable-next-line react-hooks/exhaustive-deps }, [matchedDestId]); // Post-mount re-read of flows. Catches the cold-start race where the // Expo module's reactContext is briefly null and getStringPref returns // the default. useEffect(() => { try { const fresh = loadFlows(); if (fresh.length !== flows.length) _setFlows(fresh); } catch {} // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Last-seen incoming URL + timestamp. The dedup window collapses // the redundant fan-out (expo-linking event + native event + the // foreground poll all firing the same URL within ms of each other) // without permanently blocking the same URL from being shared again // later in the session. After REINTAKE_WINDOW_MS the same URL is // treated as a fresh intake. const lastConsumed = useRef<{ url: string; ts: number } | null>(null); // Refs the event listeners read instead of closing over render-scoped // state — keeps share-intake / auto-fire working for Flows created or // edited later in the same session. const flowsRef = useRef(flows); const editingFlowRef = useRef(editingFlow); useEffect(() => { flowsRef.current = flows; }, [flows]); useEffect(() => { editingFlowRef.current = editingFlow; }, [editingFlow]); // When any blocking modal is open we stash the incoming URL instead // of slamming it into the field underneath. Drained by the effect // below once every modal closes. const anyModalOpen = editingFlow !== null || actionFlow !== null || openOptionsFlow !== null || newFlowPickerOpen || aboutOpen || donateOpen || settingsOpen || showPresets; const anyModalOpenRef = useRef(anyModalOpen); const pendingIntakeRef = useRef(null); useEffect(() => { anyModalOpenRef.current = anyModalOpen; }, [anyModalOpen]); // Whenever any blocking modal opens, drop URL-field focus so the // keyboard isn't trapped under the modal sheet. Cheap to call // every transition; no-op when nothing's focused. useEffect(() => { if (anyModalOpen) Keyboard.dismiss(); }, [anyModalOpen]); const refreshDefaultStatus = () => { try { setIsDefault(isDefaultBrowser()); } catch { setIsDefault(false); } }; const refreshInstalled = () => { try { setInstalled(new Set(getInstalledBrowsers())); } catch { setInstalled(installedRef.current); } }; const consumeIncoming = (incoming: string | null) => { if (!isHttpUrl(incoming)) return; // Preserve the original URL as received — tracking-param strip is // applied only to the *final* launch URL (computed in launchPreview // and used by handleOpenFlow), so the field always shows what the // sender actually sent. Just drop the bare-root "/" that // URL.toString() might have added. const cleaned = stripBareRootSlash(incoming); // Modal blocker: if the user is mid-edit (or in any blocking // modal), don't slam the field underneath them. Stash and let the // close-effect below drain it once the modal goes away. Latest // wins — if two URLs arrive during the same modal session, the // second replaces the first. if (anyModalOpenRef.current) { pendingIntakeRef.current = cleaned; return; } const now = Date.now(); const last = lastConsumed.current; // Same URL inside the dedup window? Probably one of the redundant // pipes (expo-linking, native event, foreground poll) re-delivering. // Outside the window, treat it as a fresh intake — handy when the // user comes back to Warden and re-shares the same link. if (last && last.url === cleaned && now - last.ts < REINTAKE_WINDOW_MS) { return; } lastConsumed.current = { url: cleaned, ts: now }; setTarget(cleaned); setIsExternal(true); setErrorMsg(null); // Incoming link = the user wants to pick a Flow, not edit the // URL. Make sure no field gets focus / no keyboard pops on // activity-resume from a VIEW intent. Safe no-op if no input // was focused. Keyboard.dismiss(); // Zero-tap path: exactly one Flow opted into auto-fire? Fire it now. // Multiple matches → fall back to showing the menu, since "which one" // is ambiguous. We read from refs to stay in sync with Flows created // or edited later in the same session. const armed = flowsRef.current.filter((f) => f.autoFire); if (armed.length === 1) { // Pass the URL explicitly because setTarget(...) hasn't committed // to the rendered state by the time we want to launch. handleOpenFlow(armed[0], cleaned); } }; // Drain any stashed intake once all modals close. Runs whenever // `anyModalOpen` transitions; we only fire the consume on the // open → closed edge. useEffect(() => { if (anyModalOpen) return; const stash = pendingIntakeRef.current; if (!stash) return; pendingIntakeRef.current = null; consumeIncoming(stash); // eslint-disable-next-line react-hooks/exhaustive-deps }, [anyModalOpen]); useEffect(() => { refreshDefaultStatus(); refreshInstalled(); Linking.getInitialURL().then(consumeIncoming); const urlSub = Linking.addEventListener('url', ({ url }) => { consumeIncoming(url); markViewUrlConsumed(); }); // Cold-start drain: pick up whatever VIEW/SEND intent launched the // app. extractUrlFromText handles both an explicit https?:// in the // text and a bare hostname; getInitialViewUrl handles the // link-click path. Both feed the same dedup. We no longer poll on // AppState 'active' — the native onIncomingUrl / onSharedTextChanged // events fire reliably for warm starts and the cold-start drain // here covers the rest. consumeIncoming(getInitialViewUrl()); consumeIncoming(extractUrlFromText(getInitialSharedText())); // Native fires these as soon as the matching intent lands in our // running task. Avoids waiting for an AppState transition that may // never come (the activity sometimes never loses focus while the OS // routes the new intent). const shareSub = onSharedTextChanged((text) => { consumeIncoming(extractUrlFromText(text)); markSharedTextConsumed(); }); const viewSub = onIncomingUrl((url) => { consumeIncoming(url); markViewUrlConsumed(); }); // Native intent drain. Called on resume and again with short // delays — Android sometimes dispatches the new VIEW intent to // onNewIntent AFTER AppState 'active' fires, so the immediate // read can return the stale (previous) activity.intent and miss // the link. Each retry is idempotent via the native consumed // flags + JS dedup window; worst case is a couple of no-op pings. const drainNativeIntents = () => { consumeIncoming(getInitialViewUrl()); consumeIncoming(extractUrlFromText(getInitialSharedText())); }; const appStateSub = AppState.addEventListener('change', (state) => { if (state === 'active') { refreshDefaultStatus(); refreshInstalled(); // Three-shot drain. 0ms catches the common case. 250ms covers // same-tick races (intent arrives just after 'active'). 1000ms // is the long backstop for slower cold-resume paths. drainNativeIntents(); setTimeout(drainNativeIntents, 250); setTimeout(drainNativeIntents, 1000); } }); return () => { urlSub.remove(); shareSub.remove(); viewSub.remove(); appStateSub.remove(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Pull launcher icons for every known browser, not just the visible list. // Hidden / deprecated rows benefit from showing the real icon too. For // packages that aren't installed, getBrowserIcon returns null and the row // falls back to the colored swatch. We cache each successful fetch and // skip browsers we've already fetched. useEffect(() => { const next: Record = { ...icons }; let changed = false; BROWSERS.forEach((b) => { if (next[b.pkg]) return; // already cached const data = getBrowserIcon(b.pkg); if (data) { next[b.pkg] = data; changed = true; } }); if (changed) setIcons(next); // eslint-disable-next-line react-hooks/exhaustive-deps }, [installed]); const handleShare = async () => { if (!target) return; // Sharing out = the user's done editing. Drop focus so the // keyboard isn't covering the share sheet. Keyboard.dismiss(); try { await Share.share({ message: target, url: target }); } catch {} }; const handlePaste = async () => { try { const text = await Clipboard.getStringAsync(); if (text) { const url = extractUrlFromText(text) ?? text.trim(); setTarget(url); setErrorMsg(null); } } catch {} }; const handleOpenFlow = async (f: Flow, urlOverride?: string) => { // User picked a Flow → done editing the URL field. Drop focus // so the keyboard isn't covering anything by the time the // browser launches (or the action sheet appears). Keyboard.dismiss(); const raw = urlOverride ?? targetRef.current; const b = BROWSERS.find((x) => x.pkg === f.browserPkg); if (!b) { setErrorMsg(`Browser ${f.browserPkg} is not installed.`); return; } // @config placeholder: the user picked the generic "Browser flags" // preset, so we substitute this browser's specific configUrl and // route through share. The OS firewalls internal schemes from // ACTION_VIEW; Share is the only path that lets the user actually // open the page (typically via Copy → paste in address bar). if (raw === CONFIG_PLACEHOLDER) { if (!b.configUrl) { setErrorMsg(`${b.name} doesn't expose a flags page.`); return; } setErrorMsg(null); try { await Share.share({ message: b.configUrl, url: b.configUrl }); } catch { // User dismissed the share sheet — no error to surface. } if (isExternal || urlOverride) BackHandler.exitApp(); return; } // Manually-entered flag URL (e.g. user typed `about:config` // directly into the field). Same share treatment, but the URL is // taken as-is rather than per-browser. if (isFlagLinkUrl(raw)) { setErrorMsg(null); try { await Share.share({ message: raw, url: raw }); } catch {} if (isExternal || urlOverride) BackHandler.exitApp(); return; } // Legacy "Browser config" sentinel — preserved for any saved // state from older builds. New flows use CONFIG_PLACEHOLDER above. if (raw === BROWSER_CONFIG_SENTINEL) { if (!b.configUrl) return; setErrorMsg(null); await Clipboard.setStringAsync(b.configUrl); const launched = launchPackage(f.browserPkg); if (!launched) { setErrorMsg(`Could not launch ${b.name}. The browser may have been uninstalled.`); return; } ToastAndroid.show( `${b.configUrl} copied — paste in the address bar`, ToastAndroid.LONG, ); if (isExternal || urlOverride) BackHandler.exitApp(); return; } // Prefer the memoised launch preview — it's the exact URL shown in // the status row and locks in the proxy random pick the user saw. // Fall back to recomputing for the auto-fire case where the user // never saw the preview render against this URL yet. let url: string | null = null; if (!urlOverride && launchPreviewRef.current?.url) { url = launchPreviewRef.current.url; } else { const normalised = normalizeUrl(raw); if (normalised) { const unwrapped = fullyUnwrapRedirect(normalised); const stripped = stripTrackingRef.current ? stripTrackingParams(unwrapped.url).url : unwrapped.url; url = rewriteThroughProxy(stripped, proxyConfigRef.current).url; } } if (!url) return; setErrorMsg(null); // Link-leak check: warn if the URL carries credentials, emails, // JWTs, token parameters, or precise coordinates before handing // it to the chosen browser. const leaks = checkLinkLeaks(url); if (leaks.length > 0) { const summary = leakSummary(leaks); const proceed = await new Promise((resolve) => { Alert.alert( 'Sensitive data in link', summary, [ { text: 'Cancel', style: 'cancel', onPress: () => resolve(false) }, { text: 'Open anyway', onPress: () => resolve(true) }, ], { cancelable: true }, ); }); if (!proceed) return; } const launch = modeToLaunch(b, f.mode); const ok = openInBrowser(url, f.browserPkg, launch, f.extras); if (!ok) { setErrorMsg(`Could not launch ${b.name}. The browser may have been uninstalled.`); return; } // External path = arrived via share / VIEW intent. After firing the // browser we exit so back-from-browser returns to the originating app. if (isExternal || urlOverride) BackHandler.exitApp(); }; const upsertFlow = (f: Flow) => { // Soft mutual-exclusion: only one Flow may have autoFire=true at any // time. When the user toggles it on for this Flow, clear it from // every other one so the share-intake path stays unambiguous. const cleared = f.autoFire ? flows.map((x) => (x.id === f.id ? x : { ...x, autoFire: false })) : flows; const i = cleared.findIndex((x) => x.id === f.id); if (i < 0) persistFlows([...cleared, f]); else { const next = [...cleared]; next[i] = f; persistFlows(next); } }; const deleteFlow = (id: string) => { persistFlows(flows.filter((f) => f.id !== id)); }; /** * Ask before deleting — both the editor's Delete button and the * action-sheet's Delete Flow entry are one tap from "gone". A native * Alert keeps the confirmation lightweight without needing a third * stacked modal. */ const confirmDeleteFlow = (id: string, after: () => void) => { const f = flows.find((x) => x.id === id); const b = f ? BROWSERS.find((x) => x.pkg === f.browserPkg) : null; const tag = (f?.title ?? '').trim(); const label = tag ? `${b?.name ?? f?.browserPkg ?? 'Flow'} · ${tag}` : (b?.name ?? f?.browserPkg ?? 'this Flow'); Alert.alert( `Delete "${label}"?`, 'This cannot be undone.', [ { text: 'Cancel', style: 'cancel' }, { text: 'Delete', style: 'destructive', onPress: () => { deleteFlow(id); after(); }, }, ], ); }; const moveFlow = (id: string, delta: -1 | 1) => { const i = flows.findIndex((f) => f.id === id); if (i < 0) return; const j = i + delta; if (j < 0 || j >= flows.length) return; const next = [...flows]; [next[i], next[j]] = [next[j], next[i]]; persistFlows(next); }; /** Toggle autoFire on an existing Flow without re-opening the editor. */ const toggleAutoFire = (id: string) => { const current = flows.find((f) => f.id === id); if (!current) return; upsertFlow({ ...current, autoFire: !current.autoFire }); }; const startNewFlow = (browserPkg: string, profile: Profile = activeProfile): Flow => { const b = BROWSERS.find((x) => x.pkg === browserPkg); const d = defaultsForProfile(profile, browserPkg, b); return { id: newFlowId(), title: '', subtitle: '', browserPkg, mode: d.mode, extras: d.extras, profile, autoFire: false, }; }; const openDefaultAppsSettings = async () => { try { await IntentLauncher.startActivityAsync('android.settings.MANAGE_DEFAULT_APPS_SETTINGS'); } catch { try { await IntentLauncher.startActivityAsync( 'android.settings.APPLICATION_DETAILS_SETTINGS', { data: 'package:org.vikingware.webwarden' }, ); } catch { Alert.alert( 'Could not open settings', `Open Android Settings → Apps → Default apps → Browser app, and select ${APP_NAME}.`, ); } } }; return ( {/* Tap-to-dismiss handled by the ScrollView's keyboardShouldPersistTaps="handled" (above) and explicit Keyboard.dismiss() calls in handleOpenFlow / handleShare / consumeIncoming / the anyModalOpen effect. An outer Pressable wrap is unnecessary and was eating taps on the URL TextInput, immediately blurring the field. */} {/* One-row navbar: brand (logo + wordmark) on the left, the default-browser status / CTA + settings cluster on the right. Version dropped from this row — surfaced in the About modal instead, which is reachable by tapping the brand block. */} {/* Brand block is passive identity — logo + wordmark are not tappable. "About" and "Donate" beneath are the actual text-buttons, each its own Pressable. */} {/* Animated WardenWordmark disabled for now — swap back to to re-enable the moving forest gradient. */} {APP_NAME} The Browser-Link Guardian. v{BUILD_VERSION} · setAboutOpen(true)} hitSlop={6} accessibilityLabel="About" accessibilityRole="button"> About · setDonateOpen(true)} hitSlop={6} accessibilityLabel="Donate" accessibilityRole="button"> Donate {/* Shelf under the hairline: Settings left, default-browser status right. */} setSettingsOpen(true)} hitSlop={8} accessibilityLabel="Settings" style={({ pressed }) => [ styles.settingsIconBtn, pressed && styles.statusPressed, ]}> Settings {/* When already default, drop the confirmation pill — the row reads as Settings-only, no noise. When NOT default, surface a glowing CTA so the user knows the app isn't wired up to actually route links yet. */} {!isDefault ? ( [ styles.setDefaultCtaGlow, pressed && styles.statusPressed, ]}> Make Default ) : null} {/* Corner brackets visually "scope" the LINK section without drawing a full border box — keeps the quiet-rails feel while signalling that everything inside (tools row, URL field, status panel) belongs to one input region. */} {/* Scope corners removed entirely — the URL section reads as open chrome, no viewfinder framing. */} {/* Tools row — LINK zone marker + Presets + Paste + Clear on the left, Share floating on the right. Inline, no card chrome. The order reads input-flow first (presets / paste), then state cleanup (clear), with Share as the outbound action separated to the right edge. */} {/* Left cluster: LINK label + Presets. */} SOURCE setShowPresets(!showPresets)} hitSlop={6} style={({ pressed }) => [styles.testsBtn, pressed && styles.testsBtnPressed]}> Presets {/* Right cluster: optional Paste / Clear (off by default in Settings → UI) and Share, spread evenly across the remaining width with Share anchored at the right. */} {showPaste ? ( [styles.urlActionPill, pressed && styles.iconBtnPressed]} accessibilityLabel="Paste from clipboard"> Paste ) : null} {showClear ? ( { setTarget(''); setErrorMsg(null); lastConsumed.current = null; }} hitSlop={10} style={({ pressed }) => [styles.urlActionPill, pressed && styles.iconBtnPressed]} accessibilityLabel="Clear URL"> Clear ) : null} [styles.urlActionPill, pressed && styles.iconBtnPressed]} accessibilityLabel="Share this link"> Source {/* Link panel — the actual URL field (or the browser-config banner when that preset is active). Stands on its own as a separate input surface. */} {target === BROWSER_CONFIG_SENTINEL ? ( Copies the config URL · opens the browser to paste it ) : ( )} setShowPresets(false)} onPickUrl={(url) => { setTarget(url); setShowPresets(false); setErrorMsg(null); }} /> {/* URL status panel. Always rendered (except when the field is in browser-config mode, where it shows a banner instead of a URL) so the layout doesn't jump as the URL becomes valid / invalid / empty. The panel is purely informational about the URL — no Flows required. When the URL is valid we show the actual launchable URL (post-normalize + post-strip) so the user can see exactly what Warden will send to the browser. */} {target !== BROWSER_CONFIG_SENTINEL ? (() => { const trimmed = target.trim(); // Resolve the four pieces every state needs to populate. // iconColor + labelColor switch to highlight only when there's // an actual ready/transform state to celebrate. let iconName: keyof typeof Ionicons.glyphMap = 'arrow-up-outline'; let iconColor: string = Palette.textMuted; let labelColor: string = Palette.textMuted; let status = 'Paste or type a URL'; // Detail content — a string or rich React node, rendered // inside the always-present link icon row below. Defaults to // the "awaiting valid url" placeholder so the link icon // always has something to anchor; valid / proxied / flag // states overwrite below. let detailText: React.ReactNode = ( Awaiting URL ); let detailLongPress: (() => void) | undefined; let detailPress: (() => void) | undefined; let canCycle = false; if (trimmed === CONFIG_PLACEHOLDER) { iconName = 'share-outline'; iconColor = Palette.highlight; labelColor = Palette.highlight; status = 'Browser flags'; detailText = {CONFIG_PLACEHOLDER}; } else if (isFlagLinkUrl(trimmed)) { iconName = 'share-outline'; iconColor = Palette.highlight; labelColor = Palette.highlight; status = 'Flag URL · shared on Open'; detailText = {trimmed}; } else if (launchPreview) { iconName = launchPreview.via ? 'shuffle-outline' : 'checkmark-circle-outline'; iconColor = Palette.highlight; labelColor = Palette.highlight; const strippedCount = launchPreview.stripped.length; // Compose multiple status pieces — proxied + stripped can // both be true; they're joined with the same · separator // used elsewhere in the app. const pieces: string[] = []; if (launchPreview.via) pieces.push('Proxied'); if (strippedCount > 0) { pieces.push( `Removed ${strippedCount} tracking param${strippedCount === 1 ? '' : 's'}`, ); } if (pieces.length === 0) pieces.push('Direct'); status = pieces.join(' · '); canCycle = !!( launchPreview.via && launchPreview.matchedDest && enabledInstanceCount(launchPreview.matchedDest, proxyConfig) > 1 ); // When the launch URL is byte-identical to whatever the // user typed (Direct / no strip / no proxy), don't repeat // it — show a quiet "same url" placeholder instead. Long // URLs (proxied paths can balloon past a few hundred // chars) are capped at 1024 chars in the display; the // copy-on-tap path still hands back the full string. if (launchPreview.url === trimmed) { detailText = ( Same URL ); } else { const displayUrl = launchPreview.url.length > 1024 ? launchPreview.url.slice(0, 1024) + '…' : launchPreview.url; // When proxied, paint the host (via) golden so the user // can see at a glance which part of the URL Warden // rewrote. Fallback to the plain render when the host // isn't found in the string (shouldn't happen, but // keeps the line safe against pathological URLs). if (launchPreview.via) { const hostIx = displayUrl.indexOf(launchPreview.via); if (hostIx >= 0) { const before = displayUrl.slice(0, hostIx); const host = displayUrl.slice(hostIx, hostIx + launchPreview.via.length); const after = displayUrl.slice(hostIx + launchPreview.via.length); detailText = ( {before} {host} {after} ); } else { detailText = {displayUrl}; } } else { detailText = {displayUrl}; } } // Tap on the URL line: cycles to the next proxy instance // when one is available (cyclable proxy URL). Otherwise the // line is inert — copy-to-clipboard is no longer wired. if (canCycle) { detailPress = () => setProxyCycleIndex((i) => i + 1); } // Long-press shortcut into the matched proxy destination's // settings panel is still useful — kept proxied-only. if (launchPreview.via && launchPreview.matchedDest) { detailLongPress = () => setQuickProxyDestId(launchPreview.matchedDest!.id); } } else if (trimmed.length > 0) { iconName = 'alert-circle-outline'; status = 'Valid URL required'; // detailText keeps its "(Awaiting Valid URL)" default — // the link icon stays visible at all times, anchored by // this placeholder until a parseable URL lands. } return ( {/* Status line: state icon + label (with inline cycle glyph when the proxy URL is cyclable — tap anywhere on the label or glyph to cycle) on the left, Share Final pushed to the right. */} {(() => { // Cycle pill is grouped with the "Proxied" word so // any trailing pieces ("· Stripped N tracking // params") stay outside the cycle affordance — the // pill marks the part you cycle, nothing else. const pieceList = status.split(' · '); const hasProxied = canCycle && pieceList[0] === 'Proxied'; const restText = hasProxied ? pieceList.slice(1).join(' · ') : ''; return ( {hasProxied ? ( <> {/* "Proxied" + cycle pill together form the cycle affordance — one Pressable wraps both so tapping the word or the icon both advance the cycle. */} setProxyCycleIndex((i) => i + 1)} hitSlop={8} accessibilityLabel="Cycle to next proxy hostname" style={({ pressed }) => [ styles.urlHintProxiedGroup, pressed && styles.urlHintLinePressed, ]}> Proxied {restText ? ( {` · ${restText}`} ) : null} ) : ( {status} )} ); })()} { const finalUrl = launchPreview?.url ?? null; if (!finalUrl) return; Keyboard.dismiss(); try { await Share.share({ message: finalUrl, url: finalUrl }); } catch {} }} disabled={!launchPreview?.url} hitSlop={8} accessibilityLabel="Share final link" style={({ pressed }) => [ styles.urlHintShareBtn, !launchPreview?.url && styles.urlHintShareDisabled, launchPreview?.url && pressed && styles.iconBtnPressed, ]}> Final {/* URL line — link icon + final-URL text. Full row width now that the action buttons moved up to the status line. Inert when there's no parseable URL (placeholder text only); copy-on-tap in the valid / proxied / flag states. */} [ styles.urlHintLine, detailPress && pressed && styles.urlHintLinePressed, ]}> {detailText} ); })() : null} {/* Rune divider between LINK and FLOWS — mirrors the one between FLOWS and the bottom action row. ᛉ ᛟ ᛉ: Protection · Inheritance · Protection. Trimmed top margin so the URL section sits closer to the divider than the Flows-section breathing-room below it. */} ᛉ ᛟ ᛉ {/* FLOWS scope brackets temporarily disabled — comparing how the section reads with the LINK frame above standing alone. Re-add the four scopeCorner Views to restore. */} {/* Flows — saved (browser × mode × extras) recipes that each open the current URL with one tap. */} {/* Profile tab selector */} {PROFILES.map((p) => { const active = p === activeProfile; const count = flows.filter((f) => f.profile === p).length; return ( setActiveProfile(p)} style={({ pressed }) => [ styles.profileTab, active && styles.profileTabActive, pressed && styles.tabPressed, ]}> {PROFILE_LABEL[p]} {count > 0 ? ( {count} ) : null} ); })} {flows.filter((f) => f.profile === activeProfile).length === 0 ? ( setNewFlowPickerOpen(true)} accessibilityLabel="Create your first Flow" style={({ pressed }) => [ styles.onboardingCard, pressed && styles.onboardingCardPressed, ]}> {/* ᛉ algiz — the rune for protection / warding. */} No flows yet Create a flow to get started ) : ( flows.filter((f) => f.profile === activeProfile).map((f) => { const b = BROWSERS.find((x) => x.pkg === f.browserPkg); // Four modes for the right (launch) side: // - Normal URL: tap fires openInBrowser. Disabled when // the field doesn't normalise. // - @config placeholder: tap shares this browser's own // flags page (b.configUrl). Disabled for browsers // that don't expose one (Focus, DDG). // - Manually-typed flag link (chrome://, about:…): tap // shares the URL verbatim. Never disabled. // - Legacy browser-config sentinel: kept for old state; // same disable rule as @config. // Left side stays active either way so the user can still // edit the Flow or long-press for the action sheet. const isConfigPlaceholderMode = target === CONFIG_PLACEHOLDER; const isLegacySentinel = target === BROWSER_CONFIG_SENTINEL; const isFlagMode = isFlagLinkUrl(target); const isShareMode = isConfigPlaceholderMode || isLegacySentinel || isFlagMode; const launchDisabled = (isConfigPlaceholderMode || isLegacySentinel) ? !b?.configUrl : isFlagMode ? false : !normalizeUrl(target); // Row name is just the browser name now — the Flow's `title` // field has been repurposed as a free-form Tag and surfaces // as a small pill below. const rowName = formatRunicName(b?.name ?? f.browserPkg, runicNames); const tag = (f.title ?? '').trim(); return ( setEditingFlow(f)} onLongPress={() => setEditingFlow(f)} delayLongPress={400} style={({ pressed }) => [ styles.rowLeft, pressed && styles.rowPressed, ]}> {f.profile !== 'privacy' ? ( ) : null} {b && ASSET_ICONS[b.id] ? ( ) : b && icons[b.pkg] ? ( ) : ( )} {rowName} {tag ? ( {tag} ) : null} {f.autoFire ? ( ) : null} {b ? : null} handleOpenFlow(f)} onLongPress={() => setOpenOptionsFlow(f)} delayLongPress={400} style={({ pressed }) => [ styles.rowRight, launchDisabled && styles.rowDisabled, pressed && styles.rowPressed, ]}> {isConfigPlaceholderMode || isLegacySentinel ? (b?.configUrl ?? 'No flags') : isFlagMode ? 'Share\nlink' : isMaxPrivacy(f) ? 'Open\nIncognito' : 'Open'} ); }) )} {/* Viking-style rune divider between the FLOWS section and the bottom action row. Centered runes read ᛉ ᛟ ᛉ — Protection (algiz) flanking Inheritance (othala). Pure Unicode glyphs at accentDeep with wide letter-spacing so they read as a small carved ornament. */} ᛉ ᛟ ᛉ {/* Bottom action row — + New Flow only. Share Final lives in the status row above; no duplicate here. */} setNewFlowPickerOpen(true)} style={({ pressed }) => [ styles.newProfileBtn, pressed && styles.tabPressed, ]}> New Flow {errorMsg ? ( {errorMsg} ) : null} { upsertFlow(f); setEditingFlow(null); }} onDelete={(id) => confirmDeleteFlow(id, () => setEditingFlow(null))} isExisting={editingFlow ? flows.some((f) => f.id === editingFlow.id) : false} canMoveUp={ editingFlow ? flows.findIndex((f) => f.id === editingFlow.id) > 0 : false } canMoveDown={ editingFlow ? (() => { const i = flows.findIndex((f) => f.id === editingFlow.id); return i >= 0 && i < flows.length - 1; })() : false } onMove={(d) => { if (editingFlow) moveFlow(editingFlow.id, d); }} onClose={() => setEditingFlow(null)} /> f.id === actionFlow.id) ?? null : null} canMoveUp={ actionFlow ? flows.findIndex((f) => f.id === actionFlow.id) > 0 : false } canMoveDown={ actionFlow ? flows.findIndex((f) => f.id === actionFlow.id) < flows.length - 1 : false } onEdit={() => { const f = actionFlow; setActionFlow(null); if (f) setEditingFlow(f); }} onMove={(d) => { if (actionFlow) moveFlow(actionFlow.id, d); }} onClose={() => setActionFlow(null)} /> { const f = openOptionsFlow; setOpenOptionsFlow(null); if (f) handleOpenFlow(f); }} onOpenNormal={() => { const f = openOptionsFlow; setOpenOptionsFlow(null); // Strip extras + force normal mode — bypasses any privacy // configuration on the Flow for this one launch. if (f) handleOpenFlow({ ...f, mode: 'normal', extras: [] }); }} onClose={() => setOpenOptionsFlow(null)} /> {/* Step 1 of New Flow — pick a browser. The editor opens once a browser is committed, pre-populated. */} { setNewFlowPickerOpen(false); setEditingFlow(startNewFlow(pkg)); }} onClose={() => setNewFlowPickerOpen(false)} /> setAboutOpen(false)} /> setDonateOpen(false)} onPickUrl={(url) => { setTarget(url); setErrorMsg(null); setDonateOpen(false); }} /> setSettingsOpen(false)} /> {/* Quick-edit shortcut for the proxied destination — opened by long-pressing the launch URL in the status panel. Resolves the id to either the built-in destination modal or the custom one. */} {(() => { if (!quickProxyDestId) return null; const builtin = PROXY_DESTINATIONS.find((d) => d.id === quickProxyDestId); if (builtin) { return ( setQuickProxyDestId(null)} /> ); } const custom = proxyConfig.customDests.find((c) => c.id === quickProxyDestId); if (custom) { return ( setQuickProxyDestId(null)} /> ); } return null; })()} ); } /** * "What is Warden?" — opened from the info button in the top bar. * Mirrors the README's pillars so the in-app pitch and the repo readme * stay aligned. Pure text + iconography, no settings inside. */ /** * Modal version of the URL-presets picker. Replaces the earlier inline * dropdown so the picker lives on top of the screen rather than pushing * the Flow list down. Two sections: * - Browser config — sets the BROWSER_CONFIG_SENTINEL. * - Browser checks — a list of URLs known to expose specific behavior * (incognito detection, fingerprinting tests, …). */ /** * Long-press menu for the right (launch) side of a Flow row. Two options: * - Open: fires the Flow as-configured (its mode + extras). * - Open Normal: bypasses the privacy mode entirely — fresh tab, no * incognito/ephemeral, no custom extras. * Mounted by Home() and driven by the `openOptionsFlow` state. */ function OpenOptionsMenu({ flow, onOpen, onOpenNormal, onClose, }: { flow: Flow | null; onOpen: () => void; onOpenNormal: () => void; onClose: () => void; }) { const b = flow ? BROWSERS.find((x) => x.pkg === flow.browserPkg) : null; const defaultLabel = flow && isMaxPrivacy(flow) ? 'Open · Incognito' : 'Open'; return ( {}}> {b?.name ?? flow?.browserPkg ?? 'Open'} How do you want to open it? [ openOptionsStyles.row, openOptionsStyles.primary, pressed && modalStyles.actionBtnPressed, ]}> {defaultLabel} Use this Flow's configured mode + extras [ openOptionsStyles.row, openOptionsStyles.secondary, pressed && modalStyles.actionBtnPressed, ]}> Open Normal Plain tab — no incognito, no extras [ editorStyles.cancelBtn, pressed && modalStyles.actionBtnPressed, ]}> Cancel ); } const openOptionsStyles = StyleSheet.create({ sheet: { // Slightly narrower than the default sheet — this menu is intentionally // small (two options + Cancel) and shouldn't sprawl. maxWidth: 360, }, row: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 12, paddingHorizontal: 14, borderRadius: 10, marginTop: 8, borderWidth: 1, }, primary: { backgroundColor: Palette.highlight, borderColor: Palette.highlight, }, secondary: { backgroundColor: Palette.bgElevated, borderColor: Palette.border, }, rowText: { flex: 1 }, rowLabel: { fontSize: 14, fontWeight: '700', color: Palette.bg, letterSpacing: 0.2, }, rowLabelLight: { color: Palette.text }, rowHint: { fontSize: 11, color: Palette.bg, opacity: 0.7, marginTop: 2, }, rowHintLight: { color: Palette.textMuted, opacity: 1 }, }); function PresetsModal({ visible, customPresets, onSetCustomPresets, onClose, onPickUrl, }: { visible: boolean; customPresets: string[]; onSetCustomPresets: (next: string[]) => void; onClose: () => void; onPickUrl: (url: string) => void; }) { const [openGroup, setOpenGroup] = useState(null); const [customInput, setCustomInput] = useState(''); const [customError, setCustomError] = useState(null); // Tap-to-expand state for the Add URL composer. When false, a // single "+ Add URL" pill is shown; when true, the composer // (input + cancel + confirm) takes over. const [addingCustom, setAddingCustom] = useState(false); // Long-press target for the custom-preset action sheet (move/delete). const [actionUrl, setActionUrl] = useState(null); // Tab strip — Custom takes the leading position when the user // Custom always leads and is the default active tab. const [activeTab, setActiveTab] = useState<'checks' | 'custom'>('custom'); useEffect(() => { if (visible) setActiveTab('custom'); // eslint-disable-next-line react-hooks/exhaustive-deps }, [visible]); const addCustomPreset = () => { const url = normalizeUrl(customInput); if (!url) { setCustomError('Enter a valid URL (e.g. example.com or https://…)'); return; } if (customPresets.includes(url)) { setCustomError('Already in the list'); return; } onSetCustomPresets([...customPresets, url]); setCustomInput(''); setCustomError(null); setAddingCustom(false); }; const cancelAddCustom = () => { setCustomInput(''); setCustomError(null); setAddingCustom(false); }; const removeCustomPreset = (url: string) => { onSetCustomPresets(customPresets.filter((u) => u !== url)); }; const moveCustomPreset = (url: string, delta: -1 | 1) => { const i = customPresets.indexOf(url); if (i < 0) return; const j = i + delta; if (j < 0 || j >= customPresets.length) return; const next = [...customPresets]; [next[i], next[j]] = [next[j], next[i]]; onSetCustomPresets(next); }; /** * Edit a custom preset in place. Validates the new value through * normalizeUrl; if it parses, the entry at the original position * is swapped (order preserved). Returns null on success or an * error string the action sheet can surface inline. */ const editCustomPreset = (oldUrl: string, draft: string): string | null => { const next = normalizeUrl(draft); if (!next) return 'Enter a valid URL (e.g. example.com or https://…)'; if (next === oldUrl) return null; if (customPresets.includes(next)) return 'Already in the list'; const i = customPresets.indexOf(oldUrl); if (i < 0) return null; const arr = [...customPresets]; arr[i] = next; onSetCustomPresets(arr); return null; }; const confirmDeleteCustomPreset = (url: string) => { Alert.alert( `Delete "${url}"?`, 'This cannot be undone.', [ { text: 'Cancel', style: 'cancel' }, { text: 'Delete', style: 'destructive', onPress: () => { removeCustomPreset(url); setActionUrl(null); }, }, ], ); }; return ( {}}> {/* Elegant header — icon + title + close, matching the encyclopedia's chrome. */} Presets Saved & diagnostic links [ modalStyles.headerCloseBtn, pressed && modalStyles.actionBtnPressed, ]}> {/* Tab strip — Custom always leads (segmented pills, same vocabulary as the encyclopedia tabs). */} {(['custom', 'checks'] as const).map((id) => ( setActiveTab(id)} style={({ pressed }) => [ modalStyles.encTab, activeTab === id && modalStyles.encTabActive, pressed && modalStyles.actionBtnPressed, ]}> {id === 'custom' ? 'Custom' : 'Checks & Config'} ))} {activeTab === 'checks' ? ( <> {/* Each preset group is rendered as a single forward-navigation row. Tapping a row opens that group's link list in a nested modal. */} {PRESET_GROUPS.map((group) => ( setOpenGroup(group)} style={({ pressed }) => [ styles.testsRow, styles.testsRowGroup, pressed && styles.testsRowPressed, ]}> {group.label} {group.items.length} link{group.items.length === 1 ? '' : 's'} ))} ) : ( /* Custom tab — user-added URLs render flat (no submenu drill-down). Same bordered panel as before so reopening the modal feels continuous. */ {/* Tab label already reads "Custom" — drop the in-panel duplicate header, keep just the Add URL chip on the right edge when there's at least one row already. */} {!addingCustom && customPresets.length > 0 ? ( setAddingCustom(true)} hitSlop={6} style={({ pressed }) => [ styles.presetCustomAddChip, pressed && modalStyles.actionBtnPressed, ]}> Add URL ) : null} {customPresets.length === 0 ? ( !addingCustom ? ( // Empty state: a single centered "+ Add URL" CTA // replaces the previous "No custom URLs yet" hint + // small header chip combo — one obvious thing to do // when there's nothing here yet. setAddingCustom(true)} hitSlop={6} style={({ pressed }) => [ styles.presetCustomEmptyCta, pressed && modalStyles.actionBtnPressed, ]}> Add URL ) : null ) : ( <> {customPresets.map((url) => ( onPickUrl(url)} onLongPress={() => setActionUrl(url)} delayLongPress={400} style={({ pressed }) => [styles.testsRow, pressed && styles.testsRowPressed]}> {url} ))} {/* Quiet hint at the foot of the list — only shown when there's at least one row, so the affordance reads against an actual long-press target. */} Long-press a link for options )} {addingCustom ? ( <> { setCustomInput(t); if (customError) setCustomError(null); }} onSubmitEditing={addCustomPreset} placeholder="example.com or https://…" placeholderTextColor={Palette.textMuted} autoCapitalize="none" autoCorrect={false} keyboardType="url" style={styles.presetCustomInput} autoFocus /> [ styles.presetCustomAddBtn, { borderColor: Palette.border }, pressed && modalStyles.actionBtnPressed, ]}> [ styles.presetCustomAddBtn, customInput.trim().length === 0 && { opacity: 0.4 }, pressed && modalStyles.actionBtnPressed, ]}> {customError ? ( {customError} ) : null} ) : null} )} setOpenGroup(null)} onPickUrl={(url) => { setOpenGroup(null); onPickUrl(url); }} /> 0 : false} canMoveDown={ actionUrl ? customPresets.indexOf(actionUrl) < customPresets.length - 1 : false } onMove={(d) => { if (actionUrl) moveCustomPreset(actionUrl, d); }} onEdit={(draft) => { if (!actionUrl) return null; const err = editCustomPreset(actionUrl, draft); if (!err) setActionUrl(null); return err; }} onDelete={() => { if (actionUrl) confirmDeleteCustomPreset(actionUrl); }} onClose={() => setActionUrl(null)} /> ); } /** * Long-press action sheet for a single custom preset URL. Mirrors the * FlowActionSheet shape — Move up / Move down / Delete (confirmed) / * Close — but the only header is the URL itself. */ function CustomPresetActionSheet({ url, canMoveUp, canMoveDown, onMove, onEdit, onDelete, onClose, }: { url: string | null; canMoveUp: boolean; canMoveDown: boolean; onMove: (delta: -1 | 1) => void; /** Save handler — returns null on success, or an error string the * sheet should surface inline (e.g. invalid URL / duplicate). */ onEdit: (draft: string) => string | null; onDelete: () => void; onClose: () => void; }) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(''); const [editError, setEditError] = useState(null); // Reset edit state whenever the sheet opens against a different URL. useEffect(() => { if (url) { setDraft(url); setEditing(false); setEditError(null); } }, [url]); if (!url) { return ; } const commitEdit = () => { const err = onEdit(draft); if (err) setEditError(err); }; return ( {}}> Custom URL {url} {editing ? ( { setDraft(t); if (editError) setEditError(null); }} onSubmitEditing={commitEdit} placeholder="https://example.com" placeholderTextColor={Palette.textMuted} autoCapitalize="none" autoCorrect={false} keyboardType="url" style={styles.presetCustomInput} autoFocus /> { setEditing(false); setEditError(null); setDraft(url); }} hitSlop={6} style={({ pressed }) => [ styles.presetCustomAddBtn, { borderColor: Palette.border }, pressed && modalStyles.actionBtnPressed, ]}> [ styles.presetCustomAddBtn, draft.trim().length === 0 && { opacity: 0.4 }, pressed && modalStyles.actionBtnPressed, ]}> {editError ? ( {editError} ) : null} ) : ( setEditing(true)} style={({ pressed }) => [ modalStyles.actionBtnPrimary, pressed && modalStyles.actionBtnPressed, ]}> Edit {canMoveUp || canMoveDown ? ( canMoveUp && onMove(-1)} disabled={!canMoveUp} style={({ pressed }) => [ modalStyles.actionBtn, modalStyles.moveBtn, !canMoveUp && modalStyles.moveBtnDisabled, pressed && modalStyles.actionBtnPressed, ]}> Move up canMoveDown && onMove(1)} disabled={!canMoveDown} style={({ pressed }) => [ modalStyles.actionBtn, modalStyles.moveBtn, !canMoveDown && modalStyles.moveBtnDisabled, pressed && modalStyles.actionBtnPressed, ]}> Move down ) : null} [ modalStyles.actionBtn, modalStyles.actionBtnDestructive, pressed && modalStyles.actionBtnPressed, ]}> Delete )} ); } /** * Per-group preset list. Opened from PresetsModal when the user taps * a group's forward-navigation row. Same row chrome as the parent so * the navigation stack feels continuous. */ function PresetGroupModal({ group, onClose, onPickUrl, }: { group: PresetGroup | null; onClose: () => void; onPickUrl: (url: string) => void; }) { if (!group) { return ; } return ( {}}> {group.label} Drop a known URL into the link field {group.items.map((t) => ( onPickUrl(t.url)} style={({ pressed }) => [styles.testsRow, pressed && styles.testsRowPressed]}> {t.label} {t.url} ))} ); } /** * Read-only reference of every tracking key Warden strips. Sorted * alphabetically; scrollable in case the list grows. Prefix patterns * (`utm_`, `pk_`, …) sit at the top because they catch the long tail. */ function TrackingListModal({ visible, onClose, }: { visible: boolean; onClose: () => void; }) { const params = Array.from(TRACKING_PARAMS).sort(); // Bound the ScrollView with a deterministic pixel maxHeight so // the nested-flex math doesn't matter — when the parent sheet // sits inside another modal's stack on Android, percent-based // sizing can collapse silently. ~62% of the viewport leaves // room for the header, subtitle, and BackPill. const { height: viewportH } = useWindowDimensions(); const scrollMaxH = Math.max(220, Math.floor(viewportH * 0.62)); return ( {}}> Stripped parameters Removed from every launched URL. Prefix matches {TRACKING_PREFIXES.map((p) => ( {p}* ))} Exact matches {params.map((k) => ( {k} ))} {/* Per-site rules — only fire when the URL's host matches. Used for keys that collide with functional params on other domains (e.g. `t` is share-tracking on X but a video timestamp on YouTube). */} Per-site rules {(() => { // Group sites that share the same param set — e.g. // twitter.com and x.com use identical rules, so we render // them on one host line instead of two near-duplicate // blocks. const groups: { hosts: string[]; params: string[] }[] = []; for (const [host, rule] of SITE_TRACKING_RULES) { const params = Array.from(rule.remove).sort(); const key = params.join(','); const existing = groups.find((g) => g.params.join(',') === key); if (existing) existing.hosts.push(host); else groups.push({ hosts: [host], params }); } return groups.map((g) => ( {g.hosts.join(' / ')} {g.params.map((k) => ( {k} ))} )); })()} ); } /** * Privacy-proxies sub-screen — opens from Settings when "Use privacy * proxies" is on. Each row is a destination (Twitter / X, …) with a * chevron leading to a per-destination detail screen where the user * picks instances + randomise. */ function ProxiesListModal({ visible, proxyConfig, onSetProxyConfig, onClose, }: { visible: boolean; proxyConfig: ProxyConfig; onSetProxyConfig: (next: ProxyConfig) => void; onClose: () => void; }) { const [openDest, setOpenDest] = useState(null); const [openCustom, setOpenCustom] = useState(null); const [addTitle, setAddTitle] = useState(''); const [addingOpen, setAddingOpen] = useState(false); const addCustomDest = () => { const label = addTitle.trim(); if (!label) return; const fresh: CustomDest = { id: newCustomDestId(), label, matches: [], instances: [], randomize: false, disabled: [], }; onSetProxyConfig({ ...proxyConfig, customDests: [...proxyConfig.customDests, fresh], }); setAddTitle(''); setAddingOpen(false); setOpenCustom(fresh); }; return ( {}}> Privacy proxies Pick which sites get redirected, and tune hostname choice per site. {PROXY_DESTINATIONS.map((d) => { const entry = proxyConfig.dests[d.id]; const disabled = new Set(entry?.disabled ?? []); const total = d.instances.length + (entry?.custom?.length ?? 0); const enabledCount = enabledInstanceCount(d, proxyConfig); return ( setOpenDest(d)} style={({ pressed }) => [ settingsStyles.row, { marginTop: 8 }, pressed && modalStyles.actionBtnPressed, ]}> {d.label} {enabledCount} of {total} hostname{total === 1 ? '' : 's'} enabled {enabledCount > 1 ? ' · randomised' : ''} ); })} {proxyConfig.customDests.map((c) => { const enabled = c.instances.filter((h) => !c.disabled.includes(h)).length; return ( setOpenCustom(c)} style={({ pressed }) => [ settingsStyles.row, { marginTop: 8 }, pressed && modalStyles.actionBtnPressed, ]}> {c.label} CUSTOM {c.instances.length === 0 ? 'No hostnames yet — tap to configure' : `${enabled} of ${c.instances.length} hostname${c.instances.length === 1 ? '' : 's'} enabled${enabled > 1 ? ' · randomised' : ''}`} ); })} {/* Add custom proxy — inline title prompt expands when tapped. */} {!addingOpen ? ( setAddingOpen(true)} style={({ pressed }) => [ settingsStyles.addCustomDestBtn, pressed && modalStyles.actionBtnPressed, ]}> Add custom proxy ) : ( { setAddTitle(''); setAddingOpen(false); }} hitSlop={6} style={({ pressed }) => [ settingsStyles.customAddBtn, { borderColor: Palette.border }, pressed && modalStyles.actionBtnPressed, ]}> [ settingsStyles.customAddBtn, addTitle.trim().length === 0 && { opacity: 0.4 }, pressed && modalStyles.actionBtnPressed, ]}> )} setOpenDest(null)} /> setOpenCustom(null)} /> ); } /** * Detail screen for a user-defined custom destination. Same chrome as * the built-in ProxyDestinationModal (randomise + instances list) but * adds editors for the title, the match hostnames, and a delete * action — because all of those are user-supplied for a custom dest. */ function CustomProxyDestinationModal({ dest, proxyConfig, onSetProxyConfig, onClose, }: { dest: CustomDest | null; proxyConfig: ProxyConfig; onSetProxyConfig: (next: ProxyConfig) => void; onClose: () => void; }) { // Bound the ScrollView with a deterministic pixel cap — `flex: 1` // inside sheetTall under KeyboardAvoidingView doesn't propagate a // definite height on Android, so the ScrollView never overflows // and never scrolls. ~62% of viewport leaves room for the header // + action row. const { height: viewportH } = useWindowDimensions(); const scrollMaxH = Math.max(240, Math.floor(viewportH * 0.62)); const [titleDraft, setTitleDraft] = useState(''); const [matchInput, setMatchInput] = useState(''); const [matchError, setMatchError] = useState(null); const [instanceInput, setInstanceInput] = useState(''); const [instanceError, setInstanceError] = useState(null); useEffect(() => { if (dest) { setTitleDraft(dest.label); setMatchInput(''); setMatchError(null); setInstanceInput(''); setInstanceError(null); } }, [dest?.id]); // eslint-disable-line react-hooks/exhaustive-deps if (!dest) { return ; } // All mutations: clone the customDests array with one entry patched // (or removed). Keeps the rest of the config untouched. const updateThis = (patch: Partial) => { onSetProxyConfig({ ...proxyConfig, customDests: proxyConfig.customDests.map((c) => c.id === dest.id ? { ...c, ...patch } : c, ), }); }; const removeThis = () => { onSetProxyConfig({ ...proxyConfig, customDests: proxyConfig.customDests.filter((c) => c.id !== dest.id), }); onClose(); }; const commitTitle = () => { const next = titleDraft.trim(); if (next && next !== dest.label) updateThis({ label: next }); else setTitleDraft(dest.label); }; const addMatch = () => { const host = normalizeProxyHost(matchInput); if (!host) { setMatchError('Enter a valid hostname (e.g. reddit.com)'); return; } if (dest.matches.includes(host)) { setMatchError('Already in the match list'); return; } updateThis({ matches: [...dest.matches, host] }); setMatchInput(''); setMatchError(null); }; const removeMatch = (host: string) => { updateThis({ matches: dest.matches.filter((h) => h !== host) }); }; const addInstance = () => { const host = normalizeProxyHost(instanceInput); if (!host) { setInstanceError('Enter a valid hostname (e.g. redlib.example.com)'); return; } if (dest.instances.includes(host)) { setInstanceError('Already in the hostnames list'); return; } updateThis({ instances: [...dest.instances, host] }); setInstanceInput(''); setInstanceError(null); }; const removeInstance = (host: string) => { updateThis({ instances: dest.instances.filter((h) => h !== host), disabled: dest.disabled.filter((h) => h !== host), }); }; const toggleInstance = (host: string) => { const isDisabled = dest.disabled.includes(host); updateThis({ disabled: isDisabled ? dest.disabled.filter((h) => h !== host) : [...dest.disabled, host], }); }; return ( {}}> {dest.label || 'Custom proxy'} {/* Title editor — commit on blur or submit. */} Title {/* Match hosts — which sites this custom proxy intercepts. */} Match hosts {dest.matches.length === 0 ? ( No hosts yet — add one below. ) : ( dest.matches.map((host) => ( {host} removeMatch(host)} hitSlop={6} style={({ pressed }) => [ settingsStyles.instanceRemove, pressed && modalStyles.actionBtnPressed, ]}> )) )} { setMatchInput(t); if (matchError) setMatchError(null); }} onSubmitEditing={addMatch} placeholder="Match host — e.g. reddit.com" placeholderTextColor={Palette.textMuted} autoCapitalize="none" autoCorrect={false} keyboardType="url" style={settingsStyles.customAddInput} /> [ settingsStyles.customAddBtn, matchInput.trim().length === 0 && { opacity: 0.4 }, pressed && modalStyles.actionBtnPressed, ]}> {matchError ? ( {matchError} ) : null} {/* Instances — picked at random when more than one is enabled. Cycle button on the URL status row steps forward through the enabled rotation. */} Hostnames Picked at random when more than one is enabled. {dest.instances.length === 0 ? ( No hostnames yet — add one below. ) : ( dest.instances.map((host) => { const isOn = !dest.disabled.includes(host); return ( toggleInstance(host)} style={({ pressed }) => [ settingsStyles.instanceRow, pressed && modalStyles.actionBtnPressed, ]}> {host} removeInstance(host)} hitSlop={6} style={({ pressed }) => [ settingsStyles.instanceRemove, pressed && modalStyles.actionBtnPressed, ]}> ); }) )} { setInstanceInput(t); if (instanceError) setInstanceError(null); }} onSubmitEditing={addInstance} placeholder="Custom hostname" placeholderTextColor={Palette.textMuted} autoCapitalize="none" autoCorrect={false} keyboardType="url" style={settingsStyles.customAddInput} /> [ settingsStyles.customAddBtn, instanceInput.trim().length === 0 && { opacity: 0.4 }, pressed && modalStyles.actionBtnPressed, ]}> {instanceError ? ( {instanceError} ) : null} {/* Delete this custom destination — destructive, separated. */} [ settingsStyles.deleteDestBtn, pressed && modalStyles.actionBtnPressed, ]}> Delete this proxy ); } /** * Per-destination detail screen — randomise toggle on top, each * instance below with its own on/off. Driven from the parent's * proxyConfig + onSetProxyConfig so changes persist immediately. */ function ProxyDestinationModal({ dest, proxyConfig, onSetProxyConfig, onClose, }: { dest: ProxyDestination | null; proxyConfig: ProxyConfig; onSetProxyConfig: (next: ProxyConfig) => void; onClose: () => void; }) { const [addInput, setAddInput] = useState(''); const [addError, setAddError] = useState(null); if (!dest) { return ; } const entry = proxyConfig.dests[dest.id] ?? { randomize: false, disabled: [], custom: [] }; const disabled = new Set(entry.disabled); const customHosts = new Set(entry.custom); const allInstances = allInstancesFor(dest, proxyConfig); const toggleInstance = (host: string) => { const nextDisabled = disabled.has(host) ? entry.disabled.filter((h) => h !== host) : [...entry.disabled, host]; onSetProxyConfig({ ...proxyConfig, dests: { ...proxyConfig.dests, [dest.id]: { ...entry, disabled: nextDisabled } }, }); }; const addCustom = () => { const host = normalizeProxyHost(addInput); if (!host) { setAddError('Enter a valid hostname (e.g. nitter.example.com)'); return; } if (allInstances.some((i) => i.host === host)) { setAddError('That hostname is already in the list'); return; } onSetProxyConfig({ ...proxyConfig, dests: { ...proxyConfig.dests, [dest.id]: { ...entry, custom: [...entry.custom, host] }, }, }); setAddInput(''); setAddError(null); }; const removeCustom = (host: string) => { onSetProxyConfig({ ...proxyConfig, dests: { ...proxyConfig.dests, [dest.id]: { ...entry, custom: entry.custom.filter((h) => h !== host), // Also drop from disabled so we don't leak orphaned entries. disabled: entry.disabled.filter((h) => h !== host), }, }, }); }; return ( {}}> {dest.label} Hostnames Picked at random when more than one is enabled. Use the cycle button on the URL status row to step forward through them. {allInstances.map((inst) => { const isOn = !disabled.has(inst.host); const isCustom = customHosts.has(inst.host); return ( toggleInstance(inst.host)} style={({ pressed }) => [ settingsStyles.instanceRow, pressed && modalStyles.actionBtnPressed, ]}> {inst.host} {isCustom ? ( removeCustom(inst.host)} hitSlop={6} accessibilityLabel="Remove custom instance" style={({ pressed }) => [ settingsStyles.instanceRemove, pressed && modalStyles.actionBtnPressed, ]}> ) : null} ); })} {/* Add a custom instance — inline input + Add button. Sits below the existing list so the user can type a hostname like `nitter.private.coffee` and add it to the rotation alongside the defaults. */} { setAddInput(t); if (addError) setAddError(null); }} onSubmitEditing={addCustom} placeholder="Custom instance" placeholderTextColor={Palette.textMuted} autoCapitalize="none" autoCorrect={false} keyboardType="url" style={settingsStyles.customAddInput} /> [ settingsStyles.customAddBtn, addInput.trim().length === 0 && { opacity: 0.4 }, pressed && modalStyles.actionBtnPressed, ]}> {addError ? ( {addError} ) : null} ); } /** * Lightweight settings sheet. Today it carries a single toggle for * stripping tracking parameters from URLs (Brave-equivalent default). * Designed to grow — drop more rows in here as needs come up. */ function SettingsModal({ visible, stripTracking, onSetStripTracking, runicNames, onSetRunicNames, showPaste, onSetShowPaste, showClear, onSetShowClear, proxyConfig, onSetProxyConfig, onClose, }: { visible: boolean; stripTracking: boolean; onSetStripTracking: (next: boolean) => void; runicNames: boolean; onSetRunicNames: (next: boolean) => void; showPaste: boolean; onSetShowPaste: (next: boolean) => void; showClear: boolean; onSetShowClear: (next: boolean) => void; proxyConfig: ProxyConfig; onSetProxyConfig: (next: ProxyConfig) => void; onClose: () => void; }) { const [trackingListOpen, setTrackingListOpen] = useState(false); const [proxiesOpen, setProxiesOpen] = useState(false); const [activeTab, setActiveTab] = useState<'privacy' | 'ui'>('privacy'); return ( {}}> Settings {/* Tab strip — Privacy (strip-tracking, strip-referrer, privacy proxies) vs UI (display flourishes like the runic browser names). Pure visual grouping; settings are still shared one StyleSheet. */} setActiveTab('privacy')} style={({ pressed }) => [ settingsStyles.tab, activeTab === 'privacy' && settingsStyles.tabActive, pressed && modalStyles.actionBtnPressed, ]}> Privacy setActiveTab('ui')} style={({ pressed }) => [ settingsStyles.tab, activeTab === 'ui' && settingsStyles.tabActive, pressed && modalStyles.actionBtnPressed, ]}> UI {activeTab === 'privacy' ? <> {/* Row split into two distinct tap zones — text body (left of the rowSep) opens the info modal; switch zone (sep + track, right of the rowSep) toggles. The outer container is a plain View, so tapping anywhere hits one of the two zones cleanly. */} setTrackingListOpen(true)} style={({ pressed }) => [ settingsStyles.rowBodyZone, settingsStyles.rowBodyZoneRow, pressed && modalStyles.actionBtnPressed, ]}> Remove Tracking Params Strip tracking parameters from launched URLs onSetStripTracking(!stripTracking)} hitSlop={6} style={({ pressed }) => [ settingsStyles.rowSwitchZone, pressed && modalStyles.actionBtnPressed, ]}> setTrackingListOpen(false)} /> {/* Use proxies — split row: body opens the destinations list, switch zone toggles the master. Uniform with the Tracking row above. */} setProxiesOpen(true)} style={({ pressed }) => [ settingsStyles.rowBodyZone, settingsStyles.rowBodyZoneRow, pressed && modalStyles.actionBtnPressed, ]}> Use proxies Redirect sites to privacy-respecting proxies onSetProxyConfig({ ...proxyConfig, enabled: !proxyConfig.enabled }) } hitSlop={6} style={({ pressed }) => [ settingsStyles.rowSwitchZone, pressed && modalStyles.actionBtnPressed, ]}> setProxiesOpen(false)} /> : null} {activeTab === 'ui' ? <> {/* Runic browser names. No detail screen → body taps fall through to the same toggle as the switch zone. */} onSetRunicNames(!runicNames)} style={({ pressed }) => [ settingsStyles.rowBodyZone, pressed && modalStyles.actionBtnPressed, ]}> Runic browser names Render browser names in Elder Futhark runes onSetRunicNames(!runicNames)} hitSlop={6} style={({ pressed }) => [ settingsStyles.rowSwitchZone, pressed && modalStyles.actionBtnPressed, ]}> {/* Show Paste. No detail screen → body toggles. */} onSetShowPaste(!showPaste)} style={({ pressed }) => [ settingsStyles.rowBodyZone, pressed && modalStyles.actionBtnPressed, ]}> Show Paste Show the Paste pill in the SOURCE tools row onSetShowPaste(!showPaste)} hitSlop={6} style={({ pressed }) => [ settingsStyles.rowSwitchZone, pressed && modalStyles.actionBtnPressed, ]}> {/* Show Clear. No detail screen → body toggles. */} onSetShowClear(!showClear)} style={({ pressed }) => [ settingsStyles.rowBodyZone, pressed && modalStyles.actionBtnPressed, ]}> Show Clear Show the Clear pill in the SOURCE tools row onSetShowClear(!showClear)} hitSlop={6} style={({ pressed }) => [ settingsStyles.rowSwitchZone, pressed && modalStyles.actionBtnPressed, ]}> : null} [ editorStyles.cancelBtn, pressed && modalStyles.actionBtnPressed, ]}> Close ); } const settingsStyles = StyleSheet.create({ sheet: { maxWidth: 380 }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, marginBottom: 12, }, // Two-up tab strip — Privacy | UI. Active tab gets a filled // surface + bright text; inactive tabs read as bordered pills. tabStrip: { flexDirection: 'row', gap: 6, marginBottom: 12, }, tab: { flex: 1, paddingVertical: 8, paddingHorizontal: 12, borderRadius: 8, borderWidth: 1, borderColor: Palette.border, alignItems: 'center', backgroundColor: Palette.bgElevated, }, tabActive: { borderColor: Palette.accentBright, backgroundColor: 'rgba(143,184,122,0.10)', }, tabLabel: { fontSize: 12, fontWeight: '600', color: Palette.textMuted, letterSpacing: 0.3, }, tabLabelActive: { color: Palette.highlight, }, row: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 11, paddingHorizontal: 12, borderRadius: 11, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bgElevated, }, rowText: { flex: 1, gap: 3 }, // Tap-zones for the split row pattern (Remove Tracking Params): // body zone on the left opens the info modal; switch zone on // the right (separator + track) toggles. Each zone claims its // own padding so the tap targets stretch to the full row height // and there's no dead space that falls through to the wrong // handler. rowBodyZone: { flex: 1, gap: 3, paddingVertical: 4, paddingRight: 4, }, // Variant used by rows whose body opens a detail screen — adds // a trailing chevron so the affordance reads as "tap for more" // rather than just static text. rowBodyZoneRow: { flexDirection: 'row', alignItems: 'center', gap: 8, }, rowSwitchZone: { flexDirection: 'row', alignItems: 'center', paddingVertical: 4, }, // Always-visible vertical divider between the label area and the // toggle / chevron. Makes the boundary explicit so the rightmost // control reads as a distinct affordance, not part of the row's // text column. rowSep: { width: 1, // Explicit height (not stretch) so the divider extends past // the natural row content — reads as a deliberate column rule // between the body and switch zones rather than a hairline // that hugs the text. height: 40, alignSelf: 'center', backgroundColor: Palette.border, marginHorizontal: 16, }, // Label + info-icon line inside the row text column. rowLabelRow: { flexDirection: 'row', alignItems: 'center', gap: 6, }, rowLabel: { fontSize: 13, color: Palette.text, fontWeight: '700', letterSpacing: 0.2, }, rowInfo: { width: 22, height: 22, alignItems: 'center', justifyContent: 'center', borderRadius: 11, }, rowHint: { fontSize: 11, color: Palette.textMuted, lineHeight: 15, }, // ─── Tracking-list modal ─────────────────────────────────────────── listSheet: { maxWidth: 380 }, listSubtitle: { fontSize: 12, color: Palette.textMuted, lineHeight: 17, marginBottom: 10, }, listScroll: { // No flex — when sheetTall has no leftover slack (its other // children + padding already fit within maxHeight 88%), a // flex:1 ScrollView resolves to 0 height and the chip rows // vanish. The inline maxHeight prop applied at the call site // does the bounding instead; the ScrollView sizes to its // natural content, capped at that pixel ceiling. borderTopWidth: 1, borderBottomWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bg, }, listScrollContent: { paddingVertical: 12, paddingHorizontal: 12, }, listSectionTitle: { fontSize: 10, color: Palette.accentBright, fontWeight: '700', letterSpacing: 1, textTransform: 'uppercase', marginBottom: 6, }, chipGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 4, }, chip: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: 5, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bgElevated, }, chipText: { fontSize: 10, color: Palette.text, fontFamily: Fonts?.mono, }, // Per-site block — hostname header sits above its chip grid. siteBlock: { marginTop: 8 }, siteHost: { fontSize: 11, color: Palette.accentBright, fontFamily: Fonts?.mono, fontWeight: '600', marginBottom: 5, }, // Chevron tucked between the row text and the switch, used by rows // that lead to a deeper config screen but also carry a toggle. rowChevron: { width: 24, height: 24, alignItems: 'center', justifyContent: 'center', borderRadius: 12, }, // Compact row used for each proxy instance in the destination // detail screen. Same border/bg as the main `row` but tighter // padding so a list of 5–10 instances stays readable. instanceRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 10, paddingVertical: 9, paddingHorizontal: 12, borderRadius: 9, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bgElevated, marginTop: 6, }, instanceHost: { flex: 1, fontSize: 12, color: Palette.text, fontFamily: Fonts?.mono, }, // Small × button rendered between the host text and the switch for // user-added instances only — built-in defaults can't be deleted, // only toggled off. instanceRemove: { width: 22, height: 22, alignItems: 'center', justifyContent: 'center', borderRadius: 11, marginRight: 4, }, // Inline "add custom" composer at the bottom of the instances list. customAddRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: 10, paddingVertical: 7, paddingLeft: 12, paddingRight: 6, borderRadius: 9, borderWidth: 1, borderStyle: 'dashed', borderColor: Palette.border, backgroundColor: Palette.bg, }, customAddInput: { flex: 1, fontSize: 12, color: Palette.text, fontFamily: Fonts?.mono, paddingVertical: 0, paddingHorizontal: 0, }, customAddBtn: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: 14, borderWidth: 1, borderColor: Palette.highlight, backgroundColor: Palette.bgElevated, }, customAddError: { fontSize: 11, color: '#d4a04a', fontStyle: 'italic', marginTop: 6, paddingHorizontal: 4, }, // ─── Custom destination chrome ───────────────────────────────────── customBadge: { paddingHorizontal: 6, paddingVertical: 1, borderRadius: 6, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bg, }, customBadgeText: { fontSize: 8, color: Palette.accentBright, fontWeight: '700', letterSpacing: 0.6, }, addCustomDestBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, paddingVertical: 11, paddingHorizontal: 12, marginTop: 10, borderRadius: 11, borderWidth: 1, borderStyle: 'dashed', borderColor: Palette.highlight, backgroundColor: Palette.bg, }, addCustomDestText: { fontSize: 12, color: Palette.highlight, fontWeight: '700', letterSpacing: 0.3, }, customDestScroll: { // flex:1 so the ScrollView claims the leftover vertical space // inside the bounded sheetTall — without it, content past the // sheet's maxHeight gets clipped but never becomes scrollable. flex: 1, }, // Generic "form field" row inside the custom-destination editor — // borders + bg match the per-instance rows for visual consistency. fieldRow: { paddingVertical: 9, paddingHorizontal: 12, borderRadius: 9, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bgElevated, }, fieldInput: { fontSize: 13, color: Palette.text, paddingVertical: 0, paddingHorizontal: 0, }, emptyNote: { fontSize: 11, color: Palette.textMuted, fontStyle: 'italic', paddingHorizontal: 4, marginBottom: 4, }, deleteDestBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, paddingVertical: 10, paddingHorizontal: 12, marginTop: 16, borderRadius: 10, borderWidth: 1, borderColor: '#c87070', backgroundColor: Palette.bg, }, deleteDestText: { fontSize: 12, color: '#c87070', fontWeight: '700', letterSpacing: 0.3, }, }); // Donation destination — NOWPayments crypto donation page. The // hostname is shown to the user as the verifiable identity; the // /donation/VFY0DTM tail is the merchant id. const DONATION_URL = 'https://nowpayments.io/donation/VFY0DTM'; /** * Donate modal — short pitch + the donation URL surfaced as a panel * with two affordances: "Set as URL" drops the link into the home * field so the user can open it through any saved Flow (browser of * their choice, private mode, etc.); "Share" hands the link off * outside Warden. Copy lives on the long-press of the URL panel so * the buttons stay clean. */ function DonateModal({ visible, onClose, onPickUrl, }: { visible: boolean; onClose: () => void; onPickUrl: (url: string) => void; }) { return ( {}}> {/* Elegant header — centered rune-flanked title, no utilitarian close X. A handshake centered between two hairline rails — clean and universally readable. */} Donate If Warden stands watch well for you, consider a tribute. It will always be open source, free, and guarding your privacy — forever. Any cryptocurrency, through the link below. {/* URL panel — purely informational. Shows the user where the "Set as URL" button is about to send them. No tap or long-press affordances; the explicit button below is the only action. */} {DONATION_URL} onPickUrl(DONATION_URL)} style={({ pressed }) => [ donateStyles.primaryBtn, pressed && modalStyles.actionBtnPressed, ]} accessibilityLabel="Drop the donation URL into the link field"> Set as URL [ editorStyles.cancelBtn, pressed && modalStyles.actionBtnPressed, ]}> Close ); } const donateStyles = StyleSheet.create({ // Rune-flanked ornament that replaces the standard utilitarian // header bar — matches the in-section runeDivider visual language. headerOrnament: { flexDirection: 'row', alignItems: 'center', marginTop: 2, marginBottom: 6, paddingHorizontal: 8, }, headerRail: { flex: 1, height: 1, backgroundColor: Palette.accentDeep, opacity: 0.5, }, headerIcon: { marginHorizontal: 14 }, headerTitle: { fontSize: 17, fontWeight: '600', color: Palette.text, letterSpacing: 1.2, textAlign: 'center', marginBottom: 10, }, tagline: { fontSize: 13, color: Palette.text, lineHeight: 19, marginBottom: 8, textAlign: 'center', }, body: { fontSize: 12, color: Palette.textMuted, lineHeight: 17, marginBottom: 14, textAlign: 'center', }, urlPanel: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 9, paddingHorizontal: 12, borderRadius: 10, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bgElevated, marginBottom: 14, }, urlText: { flex: 1, fontSize: 12, color: Palette.text, fontFamily: Fonts?.mono, }, primaryBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', alignSelf: 'center', gap: 6, paddingVertical: 10, paddingHorizontal: 18, borderRadius: 10, borderWidth: 1, borderColor: Palette.highlight, backgroundColor: Palette.bgElevated, }, primaryBtnText: { fontSize: 12, color: Palette.highlight, fontWeight: '700', letterSpacing: 0.3, }, }); function AboutModal({ visible, onClose }: { visible: boolean; onClose: () => void }) { // Plain-language descriptions for users, not engineers. The README has // the technical detail; the in-app pitch should be inviting. const pillars: { icon: keyof typeof Ionicons.glyphMap; title: string; body: string }[] = [ { icon: 'arrow-redo-outline', title: 'Open links your way', body: 'Tap any link and pick which browser opens it. Or save a one-tap shortcut — a Flow — for the way you usually browse.' }, { icon: 'book-outline', title: 'Compare browsers', body: 'Every browser gets a Security and a Privacy score with the tests behind them. Modes a browser can\'t actually deliver stay disabled.' }, { icon: 'cut-outline', title: 'Clean URLs', body: 'Warden strips tracking parameters (utm_*, fbclid, gclid…) before launch. Route social and video links through privacy proxies — Nitter, Invidious, Redlib — or your own.' }, { icon: 'construct-outline', title: 'Tunable launches', body: 'Each Flow remembers exactly how you want a browser opened. One tap gives you the strongest private mode that browser supports — plus any raw intent extras you set.' }, { icon: 'shield-checkmark-outline', title: 'Private by default', body: 'Links open without revealing which app you came from. Warden itself has no internet permission in release builds.' }, ]; return ( {}}> {/* Centered app icon at the top — the full launcher tile with the rounded boundary, so the modal header reads as the same brand the user tapped to open it. */} Warden By Vikingware Every link doesn't have to land in your default browser. Warden hands you the choice — which browser opens it, in what mode — and quietly strips trackers and rewrites hosts through privacy front-ends before the launch ever fires. {pillars.map((p) => ( {p.title} {p.body} ))} Open Source. Free. Forever. No telemetry · MIT licensed {/* Build metadata moves into About — the home screen used to carry "v0.38.0 · 7d0b03f" but the SHA is only useful when the user actually wants to read About / file a bug. */} v{BUILD_VERSION} · {BUILD_COMMIT} [ editorStyles.cancelBtn, pressed && modalStyles.actionBtnPressed, ]}> Close ); } const aboutStyles = StyleSheet.create({ logo: { width: 72, height: 72, resizeMode: 'contain', alignSelf: 'center', marginBottom: 10, // Match the launcher icon's rounded squircle — Android renders the // adaptive icon with a system mask, but our in-app Image draws the // raw square. Clipping with a generous corner radius gives the same // silhouette without bundling a separate masked asset. borderRadius: 18, overflow: 'hidden', }, titleCentered: { textAlign: 'center' }, byline: { fontSize: 12, color: Palette.accentBright, fontStyle: 'italic', textAlign: 'center', letterSpacing: 0.4, marginTop: 2, marginBottom: 6, }, tagline: { fontSize: 12, color: Palette.textMuted, lineHeight: 17, marginTop: 4, marginBottom: 14, textAlign: 'center', }, grid: { gap: 2, }, pillarRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 9, paddingVertical: 6, }, pillarIcon: { marginTop: 2 }, pillarBody: { flex: 1 }, pillarTitle: { fontSize: 12, fontWeight: '700', color: Palette.text, letterSpacing: 0.2, }, pillarText: { fontSize: 11, color: Palette.textMuted, lineHeight: 15, marginTop: 1, }, freeLine: { fontSize: 12, color: Palette.highlight, fontWeight: '700', letterSpacing: 0.4, textAlign: 'center', marginTop: 14, }, footnote: { fontSize: 10, color: Palette.textMuted, letterSpacing: 0.5, textAlign: 'center', marginTop: 4, textTransform: 'uppercase', }, buildLine: { fontSize: 10, color: Palette.textMuted, fontFamily: Fonts?.mono, textAlign: 'center', marginTop: 4, opacity: 0.7, }, }); /** * Per-browser score breakdown card rendered at the top of the Privacy * Features modal. Shows the two totals + their component scores so the * user can see what drove each rating. */ /** * Friendly display labels for the privacytests.org bySection keys. * Anything missing falls back to the raw key. */ const TEST_CATEGORY_LABEL: Record = { session_1p: 'First-party state', session_3p: 'Third-party state', supercookies: 'Supercookies', trackers: 'Tracker blocking', query: 'Query-string tracking', https: 'HTTPS upgrades', misc: 'Misc', navigation: 'Navigation tracking', fingerprinting: 'Fingerprinting', }; /** * Single component-score row rendered as a label + 0–100 progress bar. * Bar fill colour gradient: red → amber → highlight as the score climbs. */ function ScoreBar({ label, score, note }: { label: string; score: number; note?: string }) { // Crude 3-stop gradient by threshold — readable and matches the dark // palette without pulling in a gradient lib. const colour = score >= 80 ? Palette.highlight : score >= 55 ? Palette.accentBright : score >= 35 ? '#d4a04a' : '#c87070'; return ( {label} {score} {note ? {note} : null} ); } function ScoreBreakdown({ browser, only, }: { browser: Browser; /** Render a single axis full-width. Omit for the two-column view. */ only?: 'security' | 'privacy'; }) { const p = privacyBreakdown(browser); const s = securityBreakdown(browser); const showS = only !== 'privacy'; const showP = only !== 'security'; return ( {showS ? ( Security {s.total} /100 {s.parts.map((part, i) => ( ))} ) : null} {showS && showP ? : null} {showP ? ( Privacy {p.total} /100 {p.parts.map((part, i) => ( ))} ) : null} ); } /** * Compact pill cluster showing what a Flow will do at a glance. Derived * directly from which intent extras are present + truthy in the Flow's * extras list, so the display can't drift from what actually gets sent. * * - ENABLE_EPHEMERAL_BROWSING = true → "Ephemeral" pill * - any Chromium/Firefox incognito hint → "Incognito" pill * - everything else falls under → "Normal" (muted) if no * other pills render * - "+N extras" counts user-custom extras * (keys outside MODE_MANAGED_KEYS). */ function FlowModePills({ flow }: { flow: Flow }) { const isTruthy = (v: string) => v === 'true' || v === '1'; const hasKey = (key: string) => flow.extras.some((e) => e.key === key && isTruthy(e.value)); const ephemeral = hasKey('androidx.browser.customtabs.extra.ENABLE_EPHEMERAL_BROWSING'); // Reuse the single source of truth for incognito-style keys — // duplicating the list here is what caused them to drift before. const incognito = INCOGNITO_KEYS.some(hasKey); // "Diff" extras = keys we don't recognise as either mode-managed or // curated suggestions. Anything outside our presets is the user // setting their own intent extras, so we count them separately. const knownKeys = new Set([ ...MODE_MANAGED_KEYS, ...EXTRA_SUGGESTIONS.map((s) => s.key), ]); const userExtras = flow.extras.filter((e) => !knownKeys.has(e.key)); // Ephemeral implies the launch goes through the Custom Tab shape // (CCT_SESSION marker on the intent), so a "Minitab" tag rides along // with it whenever Ephemeral is on. const minitab = ephemeral; if (!ephemeral && !incognito && !minitab && userExtras.length === 0) { // Plain inline text (no pill chrome) so the label sits flush // with the browser-name baseline above — matches the active-mode // rendering shape, just in the muted color. return ( Normal ); } // Active modes render as plain uppercase labels joined by middot // separators — no pill chrome around them. The "+N extras" counter // stays in its own muted pill since it's a different kind of indicator. // Order: Incognito → Ephemeral → Mini. Incognito leads because it's // the privacy primitive most users recognise; Ephemeral is the // browser-specific stronger variant; Mini is a UI shape, last. const activeSegments: string[] = []; if (incognito) activeSegments.push('Incognito'); if (ephemeral) activeSegments.push('Ephemeral'); if (minitab) activeSegments.push('Mini'); return ( {activeSegments.length > 0 ? ( {activeSegments.map((label, i) => ( {i > 0 ? : null} {label} ))} ) : null} {userExtras.length > 0 ? ( +{userExtras.length} extra{userExtras.length === 1 ? '' : 's'} ) : null} ); } function RatingsRow({ browser }: { browser: Browser }) { const p = privacyScore(browser); const s = securityScore(browser); return ( {s} /100 {p} /100 ); } // ──────────────────────────────────────────────────────────────────────── // Flow action sheet — long-press on a Flow row. // Provides edit / delete / reorder / autoFire toggle / once-overrides. // ──────────────────────────────────────────────────────────────────────── function FlowActionSheet({ flow, canMoveUp, canMoveDown, onEdit, onMove, onClose, }: { flow: Flow | null; canMoveUp: boolean; canMoveDown: boolean; onEdit: () => void; onMove: (delta: -1 | 1) => void; onClose: () => void; }) { if (!flow) { return ; } const b = BROWSERS.find((x) => x.pkg === flow.browserPkg); // Title row = browser name; subtitle = tag if set. const title = b?.name ?? flow.browserPkg; const tag = (flow.title ?? '').trim(); return ( {}}> {b && ASSET_ICONS[b.id] ? ( ) : ( )} {title} {tag ? ( {tag} ) : null} [ modalStyles.actionBtnPrimary, pressed && modalStyles.actionBtnPressed, ]}> Edit {canMoveUp || canMoveDown ? ( canMoveUp && onMove(-1)} disabled={!canMoveUp} style={({ pressed }) => [ modalStyles.actionBtn, modalStyles.moveBtn, !canMoveUp && modalStyles.moveBtnDisabled, pressed && modalStyles.actionBtnPressed, ]}> Move up canMoveDown && onMove(1)} disabled={!canMoveDown} style={({ pressed }) => [ modalStyles.actionBtn, modalStyles.moveBtn, !canMoveDown && modalStyles.moveBtnDisabled, pressed && modalStyles.actionBtnPressed, ]}> Move down ) : null} {/* No explicit Close button — the backdrop Pressable above dismisses on tap-outside (and Android system-back). */} ); } // ──────────────────────────────────────────────────────────────────────── // Flow editor modal // ──────────────────────────────────────────────────────────────────────── /** * Lightweight info dialog — replaces native `Alert.alert` for in-app * explanations so the chrome matches the rest of the modal stack. * Renders a centered sheet with an optional Ionicon, a title, a body * paragraph, and a single Got it action. Tap-backdrop also closes. */ /** * "Back" footer pill used by nested config modals (proxies list, * per-destination detail, tracking list, …) — replaces the previous * "Done"/"Close" label so the stack reads like a forward/back * browsing flow rather than a series of commit dialogs. */ function BackPill({ onPress, label = 'Back' }: { onPress: () => void; label?: string }) { return ( [ editorStyles.cancelBtn, editorStyles.backPill, pressed && modalStyles.actionBtnPressed, ]}> {label} ); } function InfoModal({ visible, title, body, icon, onClose, }: { visible: boolean; title: string; body: string; icon?: keyof typeof Ionicons.glyphMap; onClose: () => void; }) { return ( {}}> {icon ? ( ) : null} {title} {body} [ editorStyles.cancelBtn, pressed && modalStyles.actionBtnPressed, ]}> Got it ); } const infoModalStyles = StyleSheet.create({ sheet: { maxWidth: 360 }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, marginBottom: 8, }, body: { fontSize: 12, color: Palette.textMuted, lineHeight: 18, marginBottom: 4, }, }); function FlowEditorModal({ flow, installed, icons, isExisting, onSave, onDelete, onClose, onMove, canMoveUp = false, canMoveDown = false, }: { flow: Flow | null; installed: Set; icons: Record; isExisting: boolean; onSave: (f: Flow) => void; onDelete: (id: string) => void; onClose: () => void; /** Reorder this Flow in the list without leaving the editor. Same * affordance the FlowActionSheet exposes — surfaced here so the * user can edit + reposition in one place. */ onMove?: (delta: -1 | 1) => void; canMoveUp?: boolean; canMoveDown?: boolean; }) { // Bound the form ScrollView with a pixel cap — `flex: 1` here // breaks the KeyboardAvoidingView height negotiation (collapses // form to 0), and `flexShrink: 1` alone leaves the ScrollView // sized to its content so it never overflows. ~60% of viewport // leaves room for header + Save/Cancel action row. const { height: viewportH } = useWindowDimensions(); const scrollMaxH = Math.max(260, Math.floor(viewportH * 0.6)); // Local copy of the flow under edit. Reset whenever the prop changes. const [draft, setDraft] = useState(flow); const [browserPickerOpen, setBrowserPickerOpen] = useState(false); const [extraPickerOpen, setExtraPickerOpen] = useState(false); const [showExplain, setShowExplain] = useState(false); const [autolaunchInfoOpen, setAutolaunchInfoOpen] = useState(false); const [tagInfoOpen, setTagInfoOpen] = useState(false); // When the user cycles the encyclopedia with the ‹ / › chevrons, the // viewed browser diverges from the Flow's selected browser. We keep // that purely visual override here; it does NOT affect draft.browserPkg. const [explainOverride, setExplainOverride] = useState(null); useEffect(() => { setDraft(flow); // Reset transient editor state when opening a different Flow. setShowExplain(false); setExplainOverride(null); }, [flow]); if (!draft) { return ; } const browser = BROWSERS.find((b) => b.pkg === draft.browserPkg); const update = (patch: Partial) => setDraft({ ...draft, ...patch }); const addSuggestion = (s: ExtraSuggestion) => { const next: FlowExtra = { key: s.key, type: s.type, value: s.defaultValue }; update({ extras: [...draft.extras, next] }); setExtraPickerOpen(false); }; const addCustom = () => { const next: FlowExtra = { key: '', type: 'string', value: '' }; update({ extras: [...draft.extras, next] }); setExtraPickerOpen(false); }; return ( {}}> {/* Header — big tappable browser icon (re-pick) + title + info-stats pill + close. */} setBrowserPickerOpen(true)} style={({ pressed }) => [ editorStyles.editorHeaderIconWrap, pressed && modalStyles.actionBtnPressed, ]}> {browser && ASSET_ICONS[browser.id] ? ( ) : ( )} {isExisting ? 'Edit Flow' : 'New Flow'} {browser ? ( {browser.name} ) : null} {browser ? ( setShowExplain(true)} hitSlop={6} style={({ pressed }) => [ editorStyles.infoStatsPill, pressed && modalStyles.actionBtnPressed, ]}> {securityScore(browser)} {privacyScore(browser)} ) : null} [ editorStyles.editorClose, pressed && modalStyles.actionBtnPressed, ]}> {/* Tag — single-row card mirroring the Autolaunch card. Left side: icon + label + (i) info button (identical layout to Autolaunch). Right side: the input field, right-aligned so the value/placeholder sits in the same column as the switch on the row below. */} Tag setTagInfoOpen(true)} hitSlop={8} style={({ pressed }) => [ editorStyles.autoFireInfo, pressed && modalStyles.actionBtnPressed, ]}> update({ title: t })} placeholder="(optional)" placeholderTextColor={Palette.textMuted} style={editorStyles.fieldCardInput} autoCapitalize="sentences" autoCorrect={false} /> {/* Profile — single-row card: shield icon + label + segmented picker. Changing profile resets extras to profile defaults. */} Profile {PROFILES.map((p) => { const active = p === draft.profile; return ( { if (p === draft.profile) return; const b = BROWSERS.find((x) => x.pkg === draft.browserPkg); const d = defaultsForProfile(p, draft.browserPkg, b); update({ profile: p, mode: d.mode, extras: d.extras }); }} style={({ pressed }) => [ editorStyles.profilePickerBtn, active && ( p === 'privacy' ? editorStyles.profilePickerBtnPrivacy : p === 'work' ? editorStyles.profilePickerBtnWork : editorStyles.profilePickerBtnRaw ), pressed && modalStyles.actionBtnPressed, ]}> {PROFILE_LABEL[p]} ); })} {/* Autolaunch — single-row card: flash icon + label + (i) doc + iOS-style switch. No subtitle; the (i) opens the explanatory InfoModal when needed. */} update({ autoFire: !draft.autoFire })} style={({ pressed }) => [ editorStyles.autoFireRow, draft.autoFire && editorStyles.autoFireRowOn, pressed && modalStyles.actionBtnPressed, ]}> Autolaunch setAutolaunchInfoOpen(true)} hitSlop={8} style={({ pressed }) => [ editorStyles.autoFireInfo, pressed && modalStyles.actionBtnPressed, ]}> {/* Flex spacer pushes the switch to the right while keeping the info icon snug against the title. */} {/* Extras / Behaviour — clear subpanel with title bar and inline Add-new chip. Every entry maps to a documented intent extra; mode is derived from these at save time. */} Extras / Behaviour setExtraPickerOpen(true)} hitSlop={6} style={({ pressed }) => [ editorStyles.addNewChip, pressed && modalStyles.actionBtnPressed, ]}> Add new {draft.extras.length === 0 ? ( None ) : null} {draft.extras.map((e, i) => { // Reactive: if the key matches a curated suggestion (filtered // to the active browser family), surface an info button that // pops the suggestion's documentation. const doc = lookupSuggestion(e.key, draft.browserPkg); return ( { const next = [...draft.extras]; next[i] = { ...e, key: v }; update({ extras: next }); }} placeholder="extra.key" placeholderTextColor={Palette.textMuted} style={[editorStyles.extraKey, { flex: 1 }]} autoCapitalize="none" autoCorrect={false} /> {doc ? ( Alert.alert( doc.label, `${doc.hint ?? ''}\n\nKey:\n${doc.key}\n\nType: ${doc.type}`.trim(), [{ text: 'OK' }], ) } hitSlop={8} style={({ pressed }) => [ editorStyles.extraDocBtn, pressed && modalStyles.actionBtnPressed, ]}> ) : null} { const next = [...draft.extras]; const cycle: FlowExtra['type'][] = ['bool', 'string', 'int']; const ix = (cycle.indexOf(e.type) + 1) % cycle.length; next[i] = { ...e, type: cycle[ix] }; update({ extras: next }); }} style={({ pressed }) => [ editorStyles.typeChip, pressed && modalStyles.actionBtnPressed, ]}> {e.type} {(() => { // Tap-to-cycle pill for enumerated values: // - bool → 'true' / 'false' // - known enum (e.g. CCT color scheme) → the // suggestion's declared values // Anything else stays a free-text input. const enumValues = e.type === 'bool' ? [ { label: 'true', value: 'true' }, { label: 'false', value: 'false' }, ] : doc?.values ?? null; if (!enumValues) { return ( { const next = [...draft.extras]; next[i] = { ...e, value: v }; update({ extras: next }); }} placeholder="" placeholderTextColor={Palette.textMuted} style={editorStyles.extraValue} keyboardType={e.type === 'int' ? 'numeric' : 'default'} autoCapitalize="none" autoCorrect={false} /> ); } const ix = Math.max( 0, enumValues.findIndex((v) => v.value === e.value), ); const current = enumValues[ix] ?? enumValues[0]; return ( { const nextIx = (ix + 1) % enumValues.length; const next = [...draft.extras]; next[i] = { ...e, value: enumValues[nextIx].value }; update({ extras: next }); }} style={({ pressed }) => [ editorStyles.extraValue, editorStyles.extraValueDropdown, pressed && modalStyles.actionBtnPressed, ]}> {current.label} ); })()} { const next = [...draft.extras]; next.splice(i, 1); update({ extras: next }); }} hitSlop={6} style={({ pressed }) => [ editorStyles.extraRemove, pressed && modalStyles.actionBtnPressed, ]}> ); })} {/* Footer actions */} {isExisting ? ( onDelete(draft.id)} style={({ pressed }) => [ editorStyles.deleteBtn, pressed && modalStyles.actionBtnPressed, ]}> Delete ) : } {/* Reorder the Flow in the list without leaving the editor. Only shown for existing flows (drafts have no position yet). Each button greys + ignores the tap at the respective list edge. */} {isExisting && onMove ? ( <> canMoveUp && onMove(-1)} disabled={!canMoveUp} accessibilityLabel="Move flow up" style={({ pressed }) => [ editorStyles.moveBtn, !canMoveUp && editorStyles.moveBtnDisabled, canMoveUp && pressed && modalStyles.actionBtnPressed, ]}> canMoveDown && onMove(1)} disabled={!canMoveDown} accessibilityLabel="Move flow down" style={({ pressed }) => [ editorStyles.moveBtn, !canMoveDown && editorStyles.moveBtnDisabled, canMoveDown && pressed && modalStyles.actionBtnPressed, ]}> ) : null} [ editorStyles.cancelBtn, pressed && modalStyles.actionBtnPressed, ]}> Cancel onSave({ ...draft, mode: deriveModeFromExtras(draft.extras) })} style={({ pressed }) => [ editorStyles.saveBtn, pressed && modalStyles.actionBtnPressed, ]}> Save { update({ browserPkg: pkg }); setBrowserPickerOpen(false); }} onClose={() => setBrowserPickerOpen(false)} /> !draft.extras.some((e) => e.key === s.key), )} maxPrivacyPreview={extrasForMode(draft.browserPkg, true, true)} maxPrivacyAlreadySet={isMaxPrivacy(draft)} hasReferrerOverride={draft.extras.some( (e) => e.key === REFERRER_EXTRA_KEY && e.value.length > 0, )} onSetMaxPrivacy={() => { update({ extras: syncExtrasWithMode( draft.extras, draft.browserPkg, true, true, ), }); setExtraPickerOpen(false); }} onPickSuggestion={addSuggestion} onPickCustom={addCustom} onClose={() => setExtraPickerOpen(false)} /> setAutolaunchInfoOpen(false)} /> setTagInfoOpen(false)} /> setExplainOverride(b)} onClose={() => { setShowExplain(false); setExplainOverride(null); }} /> ); } /** * Sortable column header used inside the BrowserPicker table. Renders * the label plus a chevron when this column is the active sort. */ function SortHeader({ label, active, dir, onPress, style, align = 'center', icon, }: { label: string; active: boolean; dir: 'asc' | 'desc'; onPress: () => void; style?: any; /** Horizontal alignment of the label inside the cell. Browser header * uses 'flex-start' (left-aligned to match the row content). */ align?: 'flex-start' | 'center' | 'flex-end'; /** Optional Ionicons glyph rendered before the label. Used on the * Sec / Priv headers so the column icon doubles as a legend. */ icon?: keyof typeof Ionicons.glyphMap; }) { return ( [ editorStyles.sortHeader, { justifyContent: align }, style, pressed && modalStyles.actionBtnPressed, ]}> {icon ? ( ) : null} {label} {active ? ( ) : null} ); } function BrowserPicker({ visible, installed, icons, selected, onPick, onClose, }: { visible: boolean; installed: Set; icons: Record; /** Currently selected browser package, if any. Used to highlight the * active row when re-opening the picker on an existing Flow. */ selected: string; onPick: (pkg: string) => void; onClose: () => void; }) { const runicNames = React.useContext(RunicNamesContext); // Tapping a row opens the Privacy Features modal for that browser. // The modal's "Select" button is what commits — there is no separate // Continue step. Lets the user always preview a browser before // committing, which is the same affordance the old info-pill provided // but applied to the whole row. // Tap a row → sets the *pending* selection (visualised by the row's // highlight border). The Continue button at the bottom is what // actually commits, mirroring the staged selection flow used by the // mode picker. Long-press opens the encyclopedia for that browser — // info-only in this context, since selection now lives on the row tap. const [pending, setPending] = useState(selected); useEffect(() => { if (visible) setPending(selected); }, [visible, selected]); const [explainBrowser, setExplainBrowser] = useState(null); const [gradesInfoOpen, setGradesInfoOpen] = useState(false); // Sortable table columns. Defaults to Installed-desc which collapses // to the "auto" ranking — installed first, security desc, privacy desc. // Tapping the active column flips direction; tapping a different // column switches and resets to the column's default direction. // Three-state sorting per column: // reset (sortKey = null) → column's default dir → opposite dir → reset // Reset uses the "auto" ranking: installed first, then security desc, // then privacy desc — the same baseline the picker shipped with. type SortKey = 'browser' | 'security' | 'privacy'; const [sortKey, setSortKey] = useState(null); const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc'); const defaultDir: Record = { browser: 'asc', // A → Z feels natural for names security: 'desc', // high score first privacy: 'desc', // high score first }; const cycleSort = (key: SortKey) => { if (sortKey !== key) { // Inactive column → activate at its default direction. setSortKey(key); setSortDir(defaultDir[key]); return; } if (sortDir === defaultDir[key]) { // Active at default → flip to opposite. setSortDir(defaultDir[key] === 'asc' ? 'desc' : 'asc'); return; } // Active at opposite → reset. setSortKey(null); }; // Comparator. Reset (sortKey === null) falls back to the auto ranking: // installed first, then security desc, then privacy desc. Score // columns tiebreak with the other score so equal rows still order // deterministically. const list = [...BROWSERS].sort((a, b) => { if (sortKey === null) { const ia = installed.has(a.pkg) ? 0 : 1; const ib = installed.has(b.pkg) ? 0 : 1; if (ia !== ib) return ia - ib; const ds = securityScore(b) - securityScore(a); if (ds !== 0) return ds; return privacyScore(b) - privacyScore(a); } const sign = sortDir === 'desc' ? 1 : -1; if (sortKey === 'browser') { return a.name.localeCompare(b.name) * sign; } if (sortKey === 'security') { const ds = (securityScore(b) - securityScore(a)) * sign; if (ds !== 0) return ds; return privacyScore(b) - privacyScore(a); } // privacy const dp = (privacyScore(b) - privacyScore(a)) * sign; if (dp !== 0) return dp; return securityScore(b) - securityScore(a); }); return ( {}}> Select browser setGradesInfoOpen(true)} hitSlop={8} accessibilityLabel="About the scores" style={({ pressed }) => [ editorStyles.pickerTitleInfo, pressed && modalStyles.actionBtnPressed, ]}> {/* Sortable column headers. Tapping the active column flips direction; tapping a different column switches sort key and uses its default direction. Thin vertical separators between cells reinforce the table read. */} cycleSort('browser')} style={editorStyles.colBrowser} /> cycleSort('security')} style={editorStyles.colScore} icon="shield-half-outline" /> cycleSort('privacy')} style={editorStyles.colScore} icon="glasses-outline" /> {list.length === 0 ? ( — No installed browsers — ) : ( list.map((b) => { const notInstalled = !installed.has(b.pkg); // "Featured" = any browser we accent on a score axis. // Independent of the text label, which only Brave carries // now — so Tor / IronFox / Vanadium still get the lifted // background + gold icons without inline copy. const featured = !!SCORE_ACCENT[b.id]; return ( setPending(b.pkg)} onLongPress={() => setExplainBrowser(b)} delayLongPress={350} style={({ pressed }) => [ editorStyles.pickerRow, featured && editorStyles.pickerRowFeatured, b.pkg === pending && editorStyles.pickerRowSelected, pressed && modalStyles.actionBtnPressed, ]}> {ASSET_ICONS[b.id] ? ( ) : icons[b.pkg] ? ( ) : ( )} {/* Line 1: browser name + inline recommendation pill. */} {formatRunicName(b.name, runicNames)} {BROWSER_RECOMMENDATION[b.id] ? ( {BROWSER_RECOMMENDATION[b.id]} ) : null} {BROWSER_HAS_EXTENSIONS[b.id] ? ( ) : null} {/* Line 2: status / blurb. Always rendered (with empty space when there's nothing to say) so the row height stays constant whether or not the browser is installed. */} {notInstalled ? BROWSER_AVAILABILITY[b.id] ? `Not installed, ${BROWSER_AVAILABILITY[b.id].short}` : 'Not installed' : ' '} {/* Sec + Priv collapsed into one pill so the two scores read as a single rating block. The pill's total width matches two colScore cells so each half still lines up under its sortable header. Best-in-class browsers light up the matching icon in gold (see SCORE_ACCENT). */} {(() => { const accent = SCORE_ACCENT[b.id]; return ( {securityScore(b)} {privacyScore(b)} ); })()} {b.pkg === pending ? ( ) : null} ); }) )} {/* Warning slot is always rendered (with a non-breaking space when there's nothing to say) so the footer below doesn't jump when toggling between installed and not-installed selections. */} {pending && !installed.has(pending) ? 'This browser is not installed — install it first to continue.' : ' '} [ editorStyles.cancelBtn, pressed && modalStyles.actionBtnPressed, ]}> Cancel {pending && installed.has(pending) ? ( onPick(pending)} style={({ pressed }) => [ editorStyles.saveBtn, pressed && modalStyles.actionBtnPressed, ]}> Continue ) : null} {/* Centered info button — its wrapper spans the footer width via absolute positioning, but the Pressable itself is intrinsic-width and centered by the wrapper's flex. So it lands at the footer midpoint regardless of whether Continue is rendered on the right. Long-press on a row remains the gesture shortcut; this is the visible affordance. The wrapper sits in the JSX *after* Continue so taps on the overlapping center area hit Show info first (RN gives later-painted siblings hit priority). */} {pending ? ( { const b = BROWSERS.find((x) => x.pkg === pending); if (b) setExplainBrowser(b); }} style={({ pressed }) => [ editorStyles.showInfoBtn, pressed && modalStyles.actionBtnPressed, ]}> Show info ) : null} {/* Long-press opens the encyclopedia in info-only mode — selection lives on the row tap + Continue button, so the Select pill in the explanation header is suppressed here. */} setExplainBrowser(null)} /> setGradesInfoOpen(false)} /> ); } /** * Picker for a new extras-list entry. Curated documented intent extras * filtered to the active browser family + a Custom… escape hatch. */ function ExtraPicker({ visible, suggestions, maxPrivacyPreview, maxPrivacyAlreadySet, hasReferrerOverride, onSetMaxPrivacy, onPickSuggestion, onPickCustom, onClose, }: { visible: boolean; suggestions: ExtraSuggestion[]; /** The extras the "Set max privacy" recipe will apply — shown to the * user in a confirmation modal before any changes are made. */ maxPrivacyPreview: FlowExtra[]; /** When true, the "Set max privacy" row is hidden — the Flow already * carries every canonical key, so re-applying would be a no-op. */ maxPrivacyAlreadySet: boolean; /** Whether the current Flow carries a non-empty EXTRA_REFERRER override. * When true, the max-privacy preview surfaces an extra row noting the * override will be unset (so default Uri.EMPTY strip applies). */ hasReferrerOverride: boolean; onSetMaxPrivacy: () => void; onPickSuggestion: (s: ExtraSuggestion) => void; onPickCustom: () => void; onClose: () => void; }) { const [confirmMax, setConfirmMax] = useState(false); return ( {}}> Add extra {/* One-tap recipe: drops the canonical incognito + ephemeral-CCT + referrer-override extras for the active browser. Hidden when the Flow already carries the whole recipe — re-applying would be a no-op. */} {!maxPrivacyAlreadySet ? ( <> setConfirmMax(true)} style={({ pressed }) => [ editorStyles.maxPrivacyRow, pressed && modalStyles.actionBtnPressed, ]}> Set max privacy ) : null} {suggestions.map((s) => ( onPickSuggestion(s)} style={({ pressed }) => [ editorStyles.suggestionRow, pressed && modalStyles.actionBtnPressed, ]}> {s.label} {s.key} {s.hint ? ( {s.hint} ) : null} ))} [ editorStyles.suggestionRow, editorStyles.customRow, pressed && modalStyles.actionBtnPressed, ]}> Custom… Type any key + value [ modalStyles.closeBtn, pressed && modalStyles.actionBtnPressed, ]}> Cancel {/* Confirmation modal — stacks on top of the picker, lists the exact key/type/value triples the recipe will write. */} setConfirmMax(false)}> setConfirmMax(false)}> {}}> Set max privacy {maxPrivacyPreview.length} extra {maxPrivacyPreview.length === 1 ? '' : 's'} will be applied These intent extras will be applied. Any existing entries with the same keys are overridden, and duplicates are removed. Other custom extras you've added remain untouched. {maxPrivacyPreview.length === 0 ? ( This browser has no canonical max-privacy recipe. ) : ( maxPrivacyPreview.map((e, i) => { const doc = lookupSuggestion(e.key); return ( {e.key} {e.type} = {e.value === '' ? '""' : e.value} {doc?.label ? ( {doc.label} ) : null} ); }) )} {/* When the Flow has a non-empty referrer override, max privacy will clear it so the default Uri.EMPTY strip applies. Surface that as a preview row so the user sees the change before tapping Apply. */} {hasReferrerOverride ? ( {REFERRER_EXTRA_KEY} unset = stripped (Uri.EMPTY) Referrer override removed — the app auto-strips referrer. ) : null} setConfirmMax(false)} style={({ pressed }) => [ editorStyles.cancelBtn, pressed && modalStyles.actionBtnPressed, ]}> Cancel { setConfirmMax(false); onSetMaxPrivacy(); }} disabled={maxPrivacyPreview.length === 0} style={({ pressed }) => [ editorStyles.saveBtn, maxPrivacyPreview.length === 0 && { opacity: 0.4 }, pressed && modalStyles.actionBtnPressed, ]}> Apply ); } const editorStyles = StyleSheet.create({ editorHeader: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingBottom: 10, marginBottom: 4, borderBottomWidth: 1, borderBottomColor: Palette.border, }, editorHeaderIconWrap: { padding: 2, borderRadius: 12, }, editorHeaderIcon: { width: 40, height: 40, borderRadius: 10, backgroundColor: Palette.bgElevated, }, editorHeaderText: { flex: 1 }, editorTitle: { fontSize: 15, color: Palette.text, fontWeight: '700' }, editorSubtitle: { fontSize: 11, color: Palette.textMuted, marginTop: 1, letterSpacing: 0.2, }, editorClose: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: 14, }, // Form body — consistent vertical rhythm via `gap`; each section is // a self-contained card so individual margins are unnecessary. formBody: { paddingVertical: 6, paddingHorizontal: 2, gap: 10 }, fieldLabel: { fontSize: 9, color: Palette.textMuted, fontWeight: '700', letterSpacing: 1, textTransform: 'uppercase', marginTop: 10, marginBottom: 4, paddingHorizontal: 2, }, // Single clickable pill that combines the info icon + privacy/security // scores. Tapping it opens the Privacy Features modal for the browser. // Subtle border + elevated background so it reads as one cohesive // affordance rather than a row of small icons. infoStatsPill: { flexDirection: 'row', alignItems: 'center', paddingLeft: 8, paddingRight: 9, paddingVertical: 5, borderRadius: 999, backgroundColor: Palette.bgElevated, borderWidth: 1, borderColor: Palette.border, }, infoStatsDivider: { width: 1, height: 12, backgroundColor: Palette.border, marginHorizontal: 7, }, infoStatsScore: { flexDirection: 'row', alignItems: 'center', gap: 4, }, // Plain (non-pressable) version of the score readout shown on the // right of each picker row. The whole row is now the tappable // surface, so the scores no longer need pill chrome — just two // glyph + number pairs with a thin divider. // ── BrowserPicker table layout ─────────────────────────────────── // Columns share fixed widths between header and body rows so they // visually line up. Browser column flexes; the rest are fixed-width // numeric/status columns. // Header reads as the top edge of a card table: full border with // matching rounded corners on top, flat bottom so the row cards // below feel "stitched" to it. Slightly elevated bg sets it apart // as chrome without breaking visual connection to the body. tableHeader: { flexDirection: 'row', alignItems: 'center', paddingVertical: 7, paddingHorizontal: 8, marginTop: 8, backgroundColor: Palette.bgElevated, borderTopWidth: 1, borderLeftWidth: 1, borderRightWidth: 1, borderBottomWidth: 1, borderColor: Palette.border, borderTopLeftRadius: 12, borderTopRightRadius: 12, }, tableHeaderText: { fontSize: 10, color: Palette.textMuted, fontWeight: '700', letterSpacing: 0.5, textTransform: 'uppercase', }, tableHeaderTextActive: { color: Palette.accentBright }, sortHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', }, colBrowser: { flex: 1, minWidth: 0 }, colScore: { width: 56 }, // Combined sec + priv pill rendered in the row body. Width matches // two colScore cells so each half lines up under its sortable header. scorePill: { flexDirection: 'row', alignItems: 'center', // 56 (Sec col) + 9 (header colSeparator) + 56 (Priv col) — keeps // each half of the pill lined up under its sortable header. width: 56 + 9 + 56, paddingVertical: 4, borderRadius: 999, backgroundColor: Palette.bgElevated, borderWidth: 1, borderColor: Palette.border, }, scorePillHalf: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', }, scorePillDivider: { width: 1, height: 12, backgroundColor: Palette.border, }, // Thin vertical divider rendered between header cells. Body rows // don't repeat it — the divider line at the bottom of the header // does enough work to anchor the column boundaries. colSeparator: { width: 1, alignSelf: 'stretch', backgroundColor: Palette.border, marginHorizontal: 4, }, // The Browser sort header sits flush-left so its label lines up with // the icon column below — overrides the default centered alignment // used by the score headers. cellBrowser: { flexDirection: 'row', alignItems: 'center', gap: 10, }, cellCenter: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', }, selectScoreText: { fontSize: 11, color: Palette.text, fontWeight: '700', fontVariant: ['tabular-nums'], }, input: { fontSize: 13, color: Palette.text, paddingVertical: 8, paddingHorizontal: 10, borderRadius: 9, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bgElevated, }, // Single-row "labeled input" card — icon + bold label + input on the // right, flexed to fill the rest. Matches autoFireRow's paddings, // gap, radius, border, and background so the Tag and Autolaunch // cards read as a matched pair at the top of the editor. fieldCard: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 8, paddingHorizontal: 12, borderRadius: 11, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bgElevated, }, fieldCardInput: { flex: 1, fontSize: 13, color: Palette.text, paddingVertical: 0, paddingHorizontal: 0, textAlign: 'right', // Input sits flush inside the card — no extra chrome since the // card already supplies the bordered surface. Right-aligned so // the placeholder/value sits where the Autolaunch switch sits in // its sibling card, completing the visual pairing. }, // Auto-fire card — single line: flash icon + label + (i) doc // + iOS-style switch. Tight vertical padding so it matches the // height of the Tag card above. autoFireRow: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 8, paddingHorizontal: 12, borderRadius: 11, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bgElevated, }, autoFireRowOn: { borderColor: Palette.highlight, backgroundColor: Palette.surface }, autoFireTitle: { fontSize: 13, color: Palette.text, fontWeight: '700', letterSpacing: 0.2, }, autoFireInfo: { width: 22, height: 22, alignItems: 'center', justifyContent: 'center', borderRadius: 11, }, switchTrack: { width: 34, height: 18, borderRadius: 999, backgroundColor: Palette.bgElevated, borderWidth: 1, borderColor: Palette.border, padding: 2, justifyContent: 'center', }, switchTrackOn: { backgroundColor: Palette.accent, borderColor: Palette.accentBright, }, switchThumb: { width: 12, height: 12, borderRadius: 999, backgroundColor: Palette.textMuted, }, switchThumbOn: { backgroundColor: Palette.highlight, transform: [{ translateX: 15 }], }, // Inner table — sits inside the panel's bordered card, no border of its own. extrasTable: { backgroundColor: Palette.bgElevated, }, extrasEmpty: { fontSize: 11, color: Palette.textMuted, fontStyle: 'italic', paddingVertical: 7, paddingHorizontal: 10, }, extraRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 6, paddingHorizontal: 7, borderBottomWidth: 1, borderBottomColor: Palette.border, gap: 6, }, extraFields: { flex: 1, gap: 4 }, extraKeyRow: { flexDirection: 'row', alignItems: 'center', gap: 4 }, extraDocBtn: { width: 22, height: 22, alignItems: 'center', justifyContent: 'center', borderRadius: 11, }, extraKey: { // Keys like com.google.android.apps.chrome.EXTRA_OPEN_NEW_INCOGNITO_TAB // are long — keep the font small enough that a typical key fits on // one line inside the field. fontSize: 9, color: Palette.text, fontFamily: Fonts?.mono, paddingVertical: 4, paddingHorizontal: 6, borderRadius: 6, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.surface, }, extraValueRow: { flexDirection: 'row', gap: 4, alignItems: 'center' }, // Pill variant of the value field for bool/enum keys: shows the // current label, tap to cycle through the allowed values. extraValueDropdown: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 5, paddingRight: 6, }, extraValueDropdownText: { fontSize: 11, color: Palette.text, fontFamily: Fonts?.mono, fontWeight: '600', }, typeChip: { paddingVertical: 4, paddingHorizontal: 8, borderRadius: 6, borderWidth: 1, borderColor: Palette.accentBright, backgroundColor: Palette.bg, }, typeChipText: { fontSize: 10, color: Palette.accentBright, fontWeight: '700', letterSpacing: 0.5, textTransform: 'uppercase', }, extraValue: { flex: 1, fontSize: 11, color: Palette.text, fontFamily: Fonts?.mono, paddingVertical: 4, paddingHorizontal: 6, borderRadius: 6, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.surface, }, extraRemove: { width: 24, height: 24, alignItems: 'center', justifyContent: 'center', borderRadius: 12, }, // Extras / Behaviour subpanel — cohesive bordered card with a title // bar (label + Add-new chip) above the entries table. Margin // handled by formBody's gap. extrasPanel: { borderRadius: 11, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.surfaceDim, overflow: 'hidden', }, extrasPanelHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8, paddingHorizontal: 12, borderBottomWidth: 1, borderBottomColor: Palette.border, backgroundColor: Palette.surface, }, extrasPanelTitle: { fontSize: 11, color: Palette.text, fontWeight: '700', letterSpacing: 0.6, textTransform: 'uppercase', }, addNewChip: { flexDirection: 'row', alignItems: 'center', gap: 4, paddingHorizontal: 8, paddingVertical: 4, borderRadius: 999, borderWidth: 1, borderColor: Palette.highlight, backgroundColor: Palette.bgElevated, }, addNewChipText: { fontSize: 10, color: Palette.highlight, fontWeight: '700', letterSpacing: 0.5, textTransform: 'uppercase', }, // Top-of-picker one-tap row that drops the canonical max-privacy // extras. Compact single line, a bit of breathing room above + below // so it sits as a deliberate accent row rather than crowding the // picker title and the curated list below. maxPrivacyRow: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 10, paddingHorizontal: 14, borderRadius: 999, borderWidth: 1, borderColor: Palette.accentBright, backgroundColor: Palette.accent, marginTop: 6, marginBottom: 6, marginHorizontal: 2, }, maxPrivacyRowLabel: { flex: 1, fontSize: 12, color: Palette.text, fontWeight: '700', letterSpacing: 0.3, }, pickerDivider: { height: 1, backgroundColor: Palette.border, marginVertical: 10, marginHorizontal: 4, }, // Max-privacy confirmation preview list. maxPrivacyPreviewScroll: { maxHeight: 280, marginTop: 8, marginBottom: 4, borderTopWidth: 1, borderBottomWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bgElevated, }, previewRow: { paddingVertical: 8, paddingHorizontal: 10, borderBottomWidth: 1, borderBottomColor: Palette.border, }, // Variant for the "this row will be removed" preview — muted // background + struck-through key so the destructive nature is // visible at a glance. previewRowUnset: { backgroundColor: 'rgba(200,112,112,0.06)', }, previewKey: { fontSize: 10, fontFamily: Fonts?.mono, color: Palette.text, lineHeight: 14, }, previewMeta: { flexDirection: 'row', alignItems: 'baseline', gap: 4, marginTop: 2, }, previewType: { fontSize: 9, color: Palette.accentBright, fontWeight: '700', letterSpacing: 0.5, textTransform: 'uppercase', }, previewValue: { fontSize: 11, fontFamily: Fonts?.mono, color: Palette.highlight, }, previewLabel: { fontSize: 10, color: Palette.textMuted, marginTop: 2, }, footer: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 10, }, deleteBtn: { flexDirection: 'row', alignItems: 'center', gap: 4, paddingVertical: 9, paddingHorizontal: 11, borderRadius: 9, borderWidth: 1, borderColor: '#8a3a3a', backgroundColor: '#2a1414', marginRight: 'auto', }, deleteBtnText: { fontSize: 12, color: '#f0a0a0', fontWeight: '600' }, // Compact icon-only reorder buttons in the editor footer — mirror // the cancelBtn chrome so they slot between Delete and Cancel // without claiming label space. moveBtn: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: 9, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bg, }, moveBtnDisabled: { opacity: 0.4 }, cancelBtn: { paddingVertical: 9, paddingHorizontal: 14, borderRadius: 9, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bg, }, // BackPill variant: cancel-btn chrome + row layout so the chevron // and label sit side by side. backPill: { flexDirection: 'row', alignItems: 'center', paddingLeft: 10, }, saveBtn: { paddingVertical: 9, paddingHorizontal: 18, borderRadius: 9, borderWidth: 1, borderColor: Palette.accentBright, backgroundColor: Palette.accent, }, saveBtnText: { fontSize: 13, color: Palette.text, fontWeight: '700' }, pickerScroll: { maxHeight: 420 }, // BrowserPicker variant — used inside `pickerScrollWrap` (a flex // row), so it needs `flex: 1` to claim the remaining width next to // the scrollbar track. ExtraPicker keeps the plain `pickerScroll` // because its ScrollView is a direct child of the sheet (no row // wrapper), where `flex: 1` would collapse it to nothing. // No flex — inside the (now-bounded) sheetTall, flex:1 would // collapse to 0 because the column's sibling rows (title, table // header, footer) are all content-sized. maxHeight caps the scroll // region; content overflow becomes scrollable. pickerScrollFlex: { maxHeight: 480 }, // Wrapper that lays the custom scrollbar track + the ScrollView side // by side. No top margin — the row cards now visually attach to the // header below. pickerScrollWrap: { flexDirection: 'row', maxHeight: 420, marginTop: 2, }, // Scrollbar styled to match the table header chrome — track sits in // the same elevated-surface tone as the headers above, so the // affordance feels like a continuation of the same panel. The thumb // is muted forest green, present enough to mark scroll position // without competing with the rows. scrollTrack: { width: 5, marginRight: 8, marginTop: 4, marginBottom: 4, borderRadius: 3, backgroundColor: Palette.bgElevated, overflow: 'hidden', }, scrollThumb: { width: 5, borderRadius: 3, backgroundColor: Palette.accentDeep, }, // Sheet + title overrides for the BrowserPicker — tighter padding // than the default modal sheet, centered title to match the rest of // the table's centered columns. pickerSheet: { padding: 12 }, pickerTitle: { textAlign: 'center' }, // Title + info icon centered as one group, the icon snug against // the title so it reads as an annotation of the heading rather // than a chrome-corner action. pickerTitleRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, }, pickerTitleInfo: { width: 22, height: 22, alignItems: 'center', justifyContent: 'center', borderRadius: 11, // Title font's visual center sits below the line-box center // (more ascender than descender at this weight), so the icon // looks slightly high when box-centered. Nudge it down by 3px // to land on the title's visual centerline. marginTop: 3, }, pickerFooter: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginTop: 12, position: 'relative', }, // Full-width absolute wrapper that centers its intrinsic-width child // (the actual Pressable). `alignItems: 'center'` on the default // column layout centers horizontally; `justifyContent: 'center'` // centers vertically inside the footer's height. // pointerEvents="box-none" on the wrapper means it doesn't // intercept taps in the empty side areas — only the child Pressable // swallows touches. showInfoCenter: { position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, alignItems: 'center', justifyContent: 'center', }, showInfoBtn: { flexDirection: 'row', alignItems: 'center', gap: 5, paddingVertical: 8, paddingHorizontal: 12, borderRadius: 999, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bgElevated, }, showInfoBtnText: { fontSize: 12, color: Palette.accentBright, fontWeight: '600', letterSpacing: 0.2, }, // Card-style rows: rounded corners, visible border, slight breathing // room between cards. Selected rows swap to the highlight border + // a faintly elevated background so the active pick reads at a glance. // Wrapper that hosts the Pressable + a sibling overlay used to repaint // the selected row's bottom edge. Sibling-not-child so the overlay // isn't clipped or out-drawn by the Pressable's own border render. pickerRowWrap: { marginVertical: 2, position: 'relative', }, pickerRow: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 7, paddingHorizontal: 8, borderRadius: 12, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bg, }, // Featured rows (those with a BROWSER_RECOMMENDATION label) sit on a // lifted background tone so the recommended set reads as a quiet // band — works whether sorted by name, security, or privacy. pickerRowFeatured: { backgroundColor: Palette.bgElevated, }, pickerRowSelected: { backgroundColor: Palette.bgElevated, borderColor: Palette.highlight, }, // Strip placed across the selected row's bottom edge in the wrapper's // coordinate space (sibling of the Pressable). Width inset by the // row's borderRadius so the green rounded corners stay green; the // straight middle segment is overpainted in textMuted. pickerRowBottomCover: { position: 'absolute', left: 12, right: 12, bottom: 0, height: 1, backgroundColor: Palette.textMuted, }, pickerIcon: { width: 26, height: 26, borderRadius: 6 }, pickerIconDim: { opacity: 0.4 }, pickerNameCol: { flex: 1, flexDirection: 'column', justifyContent: 'center' }, pickerName: { fontSize: 13, color: Palette.text, fontWeight: '600' }, pickerNameDim: { color: Palette.textMuted }, notInstalledTag: { fontSize: 10, color: Palette.textMuted, fontStyle: 'italic', marginTop: 1, }, pickerNameRow: { flexDirection: 'row', alignItems: 'baseline', flexWrap: 'wrap', }, // Second line under the browser name. Always rendered (with a // single space when there's nothing meaningful to show) so the row // height stays identical between installed and not-installed entries. pickerSubline: { fontSize: 10, color: 'transparent', marginTop: 1, lineHeight: 13, }, recoInline: { fontSize: 11, color: Palette.highlight, fontWeight: '600', marginLeft: 6, letterSpacing: 0.2, }, recoInlineDim: { opacity: 0.45 }, // Small puzzle glyph following the name (and any recommendation // pill) — marks browsers that support third-party extensions. // Informational only; the row tap/long-press behaviour is unchanged. extensionsIcon: { marginLeft: 6 }, extensionsIconDim: { opacity: 0.45 }, pickerWarning: { fontSize: 12, color: Palette.textMuted, fontStyle: 'italic', textAlign: 'center', marginTop: 8, lineHeight: 16, }, // Transparent variant — keeps the same metrics (fontSize / lineHeight) // so the slot's vertical space stays reserved when there's no // warning to show, preventing the footer from jumping. pickerWarningHidden: { color: 'transparent' }, suggestionRow: { paddingVertical: 9, paddingHorizontal: 10, borderRadius: 8, marginBottom: 4, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bgElevated, }, // Custom row inherits the same chrome as suggestion rows — same // padding, radius, border, and elevated background. No extra // differentiation; the label "Custom…" + hint already announce // the escape hatch on their own. customRow: {}, suggestionLabel: { fontSize: 13, color: Palette.text, fontWeight: '600' }, suggestionKey: { fontSize: 10, color: Palette.accentBright, fontFamily: Fonts?.mono, marginTop: 1, }, suggestionHint: { fontSize: 10, color: Palette.textMuted, lineHeight: 13, marginTop: 2, }, // Profile picker in FlowEditorModal — segmented pill row inside the fieldCard profilePickerRow: { flex: 1, flexDirection: 'row', gap: 3, justifyContent: 'flex-end', }, profilePickerBtn: { paddingVertical: 4, paddingHorizontal: 8, borderRadius: 7, borderWidth: 1, borderColor: Palette.border, backgroundColor: 'transparent', }, profilePickerBtnPrivacy: { borderColor: Palette.accent, backgroundColor: Palette.accent + '22', }, profilePickerBtnWork: { borderColor: '#f6c84c', backgroundColor: '#f6c84c' + '22', }, profilePickerBtnRaw: { borderColor: Palette.textMuted, backgroundColor: Palette.textMuted + '15', }, profilePickerBtnText: { fontSize: 11, fontWeight: '600', color: Palette.textMuted, }, profilePickerBtnTextActive: { color: Palette.text, }, }); /** * Standalone glossary — every encyclopedia term grouped by axis * (Security / Privacy), each with its definition shown inline. Opened * from the "Glossary" button above the encyclopedia's tabs. Uses the * sibling-pattern modal chrome so the scroll behaves. */ function GlossaryModal({ visible, onClose }: { visible: boolean; onClose: () => void }) { const { height: viewportH } = useWindowDimensions(); const scrollMaxH = Math.max(280, Math.floor(viewportH * 0.6)); const groups: { axis: 'security' | 'privacy'; label: string; icon: keyof typeof Ionicons.glyphMap }[] = [ { axis: 'security', label: 'Security', icon: 'shield-half-outline' }, { axis: 'privacy', label: 'Privacy', icon: 'glasses-outline' }, ]; return ( Glossary Terms explained {groups.map((g) => ( {g.label} {glossaryFor(g.axis).map((entry) => ( {entry.term} {entry.def} ))} ))} [editorStyles.cancelBtn, pressed && modalStyles.actionBtnPressed]}> Close ); } /** * Configuration checklist — the recommended hardening settings for a * browser, rendered as a tickable to-do list. Check state is local * only for now (resets when the modal closes); persistence per * browser will come later. */ function ConfigChecklistModal({ browser, visible, onClose, }: { browser: Browser | null; visible: boolean; onClose: () => void; }) { const { height: viewportH } = useWindowDimensions(); const scrollMaxH = Math.max(280, Math.floor(viewportH * 0.6)); // Local checked state keyed by item text. Reset on open so the // dummy list always starts fresh (no persistence yet). const [checked, setChecked] = useState>({}); useEffect(() => { if (visible) setChecked({}); }, [visible, browser?.id]); if (!browser) { return ; } const groups: { label: string; icon: keyof typeof Ionicons.glyphMap; items: string[] }[] = [ { label: 'Security', icon: 'shield-half-outline', items: securityTips(browser) }, { label: 'Privacy', icon: 'glasses-outline', items: privacyTips(browser) }, ]; const total = groups.reduce((n, g) => n + g.items.length, 0); const done = Object.values(checked).filter(Boolean).length; return ( Checklist {browser.name} · {done}/{total} done {groups.map((g) => ( {g.label} {g.items.map((item, i) => { const key = `${g.label}:${i}`; const on = !!checked[key]; return ( setChecked((c) => ({ ...c, [key]: !c[key] }))} style={({ pressed }) => [ modalStyles.checklistRow, pressed && modalStyles.actionBtnPressed, ]}> {item} ); })} ))} Tick items off as you apply them in {browser.name}. (Progress isn’t saved yet.) [editorStyles.cancelBtn, pressed && modalStyles.actionBtnPressed]}> Close ); } function PrivacyExplanationModal({ browser, onClose, onSelect, canSelect = true, list, onNavigate, }: { browser: Browser | null; onClose: () => void; /** When set, renders a "Select" button in the modal header — used when * the modal is opened from the browser-picker context. */ onSelect?: () => void; /** When false, the header Select pill is hidden even if onSelect is set. * Picker passes false for not-installed browsers. */ canSelect?: boolean; /** Optional list of browsers to cycle through with ‹ / › chevrons. The * modal computes prev/next from the current browser's index in this * list and calls onNavigate with the new browser. */ list?: Browser[]; onNavigate?: (b: Browser) => void; }) { const explanation = browser ? explainPrivacy(browser) : null; // Bound the scroll region with a deterministic pixel maxHeight so // the nested-flex math doesn't matter — sheetTall's content-sized // parent chain doesn't propagate a definite height for `flex: 1` // to claim on Android, and flexShrink alone won't shrink a // ScrollView below its content. ~50% leaves room for the header, // summary, info pills, intent rows, and Close button. const { height: viewportH } = useWindowDimensions(); // The ScrollView now wraps the WHOLE modal body (tagline + info // pills + ScoreBreakdown + protections + privacytests + intents) // so anywhere the user swipes inside the modal, scrolling engages // — the previous "split" layout had three non-scrolling rows // above the ScrollView that swallowed gestures silently. // ~50% viewport leaves room for the sticky header + close row, // sized below the typical content height so scrolling actually // overflows. const scrollMaxH = Math.max(280, Math.floor(viewportH * 0.5)); // Single tab axis — Security | Privacy. Each tab shows its score // breakdown + details, with the optimization (hardening) tips // appended at the bottom. Reset to Security whenever the modal // opens against a new browser. const [mainTab, setMainTab] = useState<'security' | 'privacy'>('security'); // Standalone glossary + configuration-checklist modal open states. const [glossaryOpen, setGlossaryOpen] = useState(false); const [checklistOpen, setChecklistOpen] = useState(false); useEffect(() => { if (browser) setMainTab('security'); }, [browser?.id]); // Two distilled intent rows the user actually cares about: // - Mini-tab (Custom Tab support) // - Incognito / Isolation (strongest private session we can request) // We pull each row's spec from the per-mode breakdown explainPrivacy // already computes, so the docs and the launch code can't drift. // Cycle through `list` with the chevrons. Wraps at the ends — feels // closer to a carousel than a dead-end. Hidden when list has 0 or 1 // entries (nothing to cycle to). const canCycle = !!(list && onNavigate && browser && list.length > 1); const cycleIdx = canCycle && browser ? list!.findIndex((b) => b.id === browser.id) : -1; const goPrev = canCycle && cycleIdx >= 0 ? () => onNavigate!(list![(cycleIdx - 1 + list!.length) % list!.length]) : null; const goNext = canCycle && cycleIdx >= 0 ? () => onNavigate!(list![(cycleIdx + 1) % list!.length]) : null; const miniMode = explanation?.modes.find((m) => m.mode === 'mini'); const privateModeRow = explanation?.modes.find((m) => m.mode === 'max' && m.supported) ?? explanation?.modes.find((m) => m.mode === 'private' && m.supported) ?? explanation?.modes.find((m) => m.mode === 'private'); return ( {/* Sibling-pattern modal chrome: the backdrop Pressable and the sheet View are SIBLINGS, not parent-child. This stops un-claimed scroll-start touches inside the sheet from bubbling up to the backdrop's onPress and closing the modal. With the previous parent-child layout, the backdrop Pressable saw every touch that ScrollView didn't claim on touch-start (RN ScrollView only claims on MOVE, not start) and fired onClose on release. */} {browser && explanation ? ( <> {/* Sticky header — outside the ScrollView so it stays visible while the user scrolls the writeup. */} {ASSET_ICONS[browser.id] ? ( ) : ( )} {browser.name} Encyclopedia {onSelect && canSelect ? ( [ modalStyles.headerSelectPill, pressed && modalStyles.actionBtnPressed, ]}> Select ) : null} {/* Top-right close — replaces the bottom Close button. */} [ modalStyles.headerCloseBtn, pressed && modalStyles.actionBtnPressed, ]}> {/* Browser summary + capability pills — general context, shown ONCE here in the sticky region above the tabs (not per-tab) so it isn't repeated when switching Security ↔ Privacy. */} {explanation.summary} {BROWSER_AVAILABILITY[browser.id] ? ( {BROWSER_AVAILABILITY[browser.id].long} ) : null} {BROWSER_HAS_EXTENSIONS[browser.id] ? ( Supports extensions ) : null} {/* Action launchers above the tabs — Glossary (left) and Configuration checklist (right beside it). */} setGlossaryOpen(true)} hitSlop={8} style={({ pressed }) => [ modalStyles.encGlossaryBtn, pressed && modalStyles.actionBtnPressed, ]}> Glossary setChecklistOpen(true)} hitSlop={8} style={({ pressed }) => [ modalStyles.encGlossaryBtn, pressed && modalStyles.actionBtnPressed, ]}> Checklist {/* Tab strip — single axis (Security | Privacy). Each tab shows its score breakdown + details, with the hardening tips appended at the bottom. Sticky above the scrolling body. */} {(['security', 'privacy'] as const).map((t) => ( setMainTab(t)} style={({ pressed }) => [ modalStyles.encTab, mainTab === t && modalStyles.encTabActive, pressed && modalStyles.actionBtnPressed, ]}> {t === 'security' ? 'Security' : 'Privacy'} ))} {/* Privacy tab carries the privacytests breakdown, built-in protections, and supported-intent rows. Security tab stops at the score breakdown. */} {mainTab === 'privacy' ? ( <> {explanation.testScore ? ( Privacytests.org score {explanation.testScore.totalPassed} {' '}/ {explanation.testScore.totalTests} tests passed v{explanation.testScore.version} · snapshot {PRIVACY_TEST_DATE} {(Object.entries(explanation.testScore.bySection) as [string, { passed: number; total: number }][]) .filter(([, v]) => v.total > 0) .map(([key, v]) => { const pct = Math.round((v.passed / v.total) * 100); const colour = pct >= 80 ? Palette.highlight : pct >= 55 ? Palette.accentBright : pct >= 35 ? '#d4a04a' : '#c87070'; return ( {TEST_CATEGORY_LABEL[key] ?? key} {v.passed}/{v.total} ); })} ) : null} {explanation.protections.length > 0 ? ( Browser protections {explanation.protections.map((line, i) => ( · {line} ))} ) : null} Supported intents {miniMode ? ( Mini-tab {miniMode.supported ? 'Supported' : 'Not supported'} {miniMode.description} ) : null} {privateModeRow ? ( Incognito / Isolation {privateModeRow.supported ? 'Supported' : 'Not supported'} {privateModeRow.description} ) : null} ) : null} {/* Optimization (hardening) tips — appended at the bottom of each tab's content. */} {mainTab === 'security' ? 'Harden security' : 'Harden privacy'} {(mainTab === 'security' ? securityTips(browser) : privacyTips(browser)).map( (tip, i) => ( {tip} ), )} Settings live inside {browser.name} itself — Warden documents them, it doesn’t apply them. {/* Bottom row now holds ONLY the browser-cycle chevrons (close moved to the header X). Hidden entirely when there's nothing to cycle to. */} {canCycle ? ( [ modalStyles.cycleBtn, pressed && modalStyles.actionBtnPressed, ]}> [ modalStyles.cycleBtn, pressed && modalStyles.actionBtnPressed, ]}> ) : null} ) : null} {/* Standalone glossary modal — all terms grouped by axis. */} setGlossaryOpen(false)} /> {/* Configuration checklist — recommended hardening settings as a to-do. Local check state only (no persistence yet). */} setChecklistOpen(false)} /> ); } const modalStyles = StyleSheet.create({ backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.65)', justifyContent: 'center', alignItems: 'center', padding: 20, }, // Used by the sibling-pattern modals (encyclopedia, etc.) — an // absolutely-filled Pressable that catches taps OUTSIDE the // centered sheet. Without this, the backdrop visual is on the // parent View (no Pressable), so taps in the dimmed margin // wouldn't close the modal. backdropFill: { ...StyleSheet.absoluteFillObject, }, sheet: { width: '100%', maxWidth: 380, backgroundColor: Palette.surface, borderWidth: 1, borderColor: Palette.border, borderRadius: 16, padding: 18, }, // KeyboardAvoidingView wrapper for modal sheets that hold TextInputs. // width:100% + maxWidth match the sheet so layout doesn't shift when // the keyboard opens; alignItems centers the sheet horizontally // inside the backdrop. maxHeight:'100%' gives sheetTall's // percent-based maxHeight a reference to compute against — without // it the sheet grew to full content height and clipped past the // viewport. // Wrap claims the full backdrop area (height + width 100%) — a // defined dimension on the wrap is what lets the nested sheet's // percent-based maxHeight (88%) and the flex:1 ScrollView inside // it both resolve cleanly. justifyContent / alignItems center the // sheet inside this full-area wrap. maxWidth keeps the wrap from // spanning past the sheet's natural width. keyboardWrap: { width: '100%', height: '100%', maxWidth: 380, alignItems: 'center', justifyContent: 'center', }, // Privacy Features modal — bounded so the ScrollView inside can actually // overflow + scroll instead of growing the sheet past the viewport. // Softer radius + a touch more padding so the cards inside don't // crash up against the outer edge. sheetTall: { width: '100%', maxWidth: 380, maxHeight: '88%', backgroundColor: Palette.surface, borderWidth: 1, borderColor: Palette.border, borderRadius: 20, padding: 16, flexShrink: 1, }, header: { flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 14, }, // Encyclopedia-only header variant: a hairline + a touch more // bottom space so the sticky header reads as a distinct band // above the scrolling writeup. headerSticky: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingBottom: 12, marginBottom: 4, borderBottomWidth: 1, borderBottomColor: Palette.border, }, headerIcon: { width: 44, height: 44, borderRadius: 10, backgroundColor: Palette.bgElevated, }, headerText: { flex: 1 }, // Encyclopedia header — browser name in the brand serif, left // aligned beside the icon. Distinct from the generic centered // modalStyles.title so reordering / sizing here doesn't ripple // into the action sheets that share `title`. encTitle: { fontFamily: Fonts?.serif, fontSize: 23, color: Palette.text, fontWeight: '400', letterSpacing: 0.3, }, encSubtitle: { fontSize: 11, color: Palette.accentBright, fontStyle: 'italic', letterSpacing: 0.6, marginTop: 1, }, // Main tab strip (Security | Privacy) — segmented pills. encTabStrip: { flexDirection: 'row', gap: 6, marginBottom: 6, }, encTab: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, paddingVertical: 8, borderRadius: 10, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bgElevated, }, encTabActive: { borderColor: Palette.highlight, backgroundColor: Palette.surface, }, encTabText: { fontSize: 12, color: Palette.textMuted, fontWeight: '700', letterSpacing: 0.4, }, encTabTextActive: { color: Palette.text }, encOptimNote: { fontSize: 10, color: Palette.textMuted, fontStyle: 'italic', lineHeight: 15, marginTop: 10, }, // Rounded inset panel for the Presets list — matches the // encyclopedia's reading-surface treatment. presetsScrollPanel: { borderWidth: 1, borderColor: Palette.border, borderRadius: 14, backgroundColor: Palette.bg, overflow: 'hidden', }, // Left-aligned launcher row above the encyclopedia tabs — Glossary // + Configuration checklist sit side by side. encGlossaryRow: { flexDirection: 'row', justifyContent: 'flex-start', gap: 8, marginBottom: 6, }, encGlossaryBtn: { flexDirection: 'row', alignItems: 'center', gap: 5, paddingVertical: 5, paddingHorizontal: 10, borderRadius: 999, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bgElevated, }, encGlossaryBtnText: { fontSize: 11, color: Palette.accentBright, fontWeight: '700', letterSpacing: 0.3, }, // Standalone GlossaryModal interior. glossaryHeaderIcon: { alignItems: 'center', justifyContent: 'center', }, glossaryGroupHeader: { flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 8, }, glossaryEntry: { marginBottom: 12 }, glossaryTerm: { fontSize: 13, color: Palette.text, fontWeight: '700', letterSpacing: 0.2, marginBottom: 2, }, glossaryDef: { fontSize: 12, color: Palette.textMuted, lineHeight: 17, }, // Configuration checklist rows. checklistRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 9, paddingVertical: 5, }, checklistText: { flex: 1, fontSize: 12, color: Palette.text, lineHeight: 17, }, checklistTextDone: { color: Palette.textMuted, textDecorationLine: 'line-through', }, headerSelectPill: { flexDirection: 'row', alignItems: 'center', gap: 4, paddingVertical: 6, paddingHorizontal: 10, borderRadius: 999, backgroundColor: Palette.highlight, }, headerSelectText: { fontSize: 12, fontWeight: '700', color: Palette.text, }, // Top-right close button in the encyclopedia header — bordered // circular tile so it reads as a tappable control, not a bare // glyph. Replaces the old bottom Close button. headerCloseBtn: { width: 34, height: 34, borderRadius: 17, alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bgElevated, marginLeft: 4, }, explainCloseRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: 12, gap: 10, }, // Left-aligned footer used by deep config modals — the BackPill // anchors to the start of the row so the chevron lives where the // user's eye expects "back" to be. backCloseRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-start', marginTop: 12, }, cycleBtn: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bgElevated, }, cycleBtnSpacer: { width: 36, height: 36 }, headerChip: { flexDirection: 'row', alignItems: 'center', gap: 5, paddingVertical: 6, paddingHorizontal: 10, borderRadius: 8, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bgElevated, }, headerChipText: { fontSize: 11, color: Palette.accentBright, fontWeight: '600', letterSpacing: 0.3 }, visibleToggle: { flexDirection: 'row', alignItems: 'center', gap: 7, paddingVertical: 6, paddingHorizontal: 10, borderRadius: 8, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bgElevated, }, visibleCheckbox: { width: 16, height: 16, borderRadius: 4, borderWidth: 1.5, borderColor: Palette.textMuted, alignItems: 'center', justifyContent: 'center', }, visibleCheckboxOn: { backgroundColor: Palette.accentBright, borderColor: Palette.accentBright, }, visibleLabel: { fontSize: 12, color: Palette.text, fontWeight: '600' }, moveRow: { flexDirection: 'row', gap: 8 }, moveBtn: { flex: 1, justifyContent: 'center' }, moveBtnDisabled: { opacity: 0.4 }, deleteAction: { borderColor: '#8a3a3a', backgroundColor: '#2a1414', }, deleteActionText: { fontSize: 14, color: '#f0a0a0', fontWeight: '500' }, title: { fontSize: 17, color: Palette.text, fontWeight: '600', textAlign: 'center', }, subtitle: { fontSize: 11, color: Palette.textMuted, fontFamily: Fonts?.mono, marginTop: 2, }, actionList: { gap: 8 }, actionBtn: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 12, paddingHorizontal: 14, borderRadius: 10, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bgElevated, }, actionBtnPrimary: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 12, paddingHorizontal: 14, borderRadius: 10, borderWidth: 1, borderColor: Palette.accentBright, backgroundColor: Palette.bgElevated, }, // Destructive variant of actionBtn — amber border + text colour so // Delete / Remove rows read as "you sure?" before tap. actionBtnDestructive: { borderColor: '#c87070' }, actionBtnPressed: { opacity: 0.75, transform: [{ scale: 0.99 }] }, actionBtnText: { fontSize: 14, color: Palette.text, fontWeight: '500' }, actionBtnPrimaryText: { fontSize: 14, color: Palette.highlight, fontWeight: '600' }, closeBtn: { marginTop: 14, paddingVertical: 12, alignItems: 'center', borderRadius: 10, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bg, }, closeBtnText: { fontSize: 14, color: Palette.textMuted, fontWeight: '500' }, summary: { fontSize: 12, color: Palette.textMuted, lineHeight: 17, marginBottom: 6, }, // Row that hosts availability + capability pills under the // encyclopedia summary. flexWrap so multiple pills line-break onto // additional rows when space runs out. infoPillsRow: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 6, marginBottom: 6, }, // Availability caveat shown under the encyclopedia summary. A // bordered pill with an info glyph so it reads as a constraint // notice (not a normal piece of text) before the user dives in. availabilityPill: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingVertical: 5, paddingHorizontal: 9, borderRadius: 999, borderWidth: 1, borderColor: Palette.accentBright, backgroundColor: Palette.bgElevated, }, availabilityPillText: { fontSize: 11, color: Palette.accentBright, fontWeight: '700', letterSpacing: 0.2, }, // Capability pill — softer chrome than the availability pill (which // is a constraint notice). Marks positive attributes like "Supports // extensions" so they read as feature badges, not warnings. capabilityPill: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingVertical: 5, paddingHorizontal: 9, borderRadius: 999, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bgElevated, }, capabilityPillText: { fontSize: 11, color: Palette.text, fontWeight: '600', letterSpacing: 0.2, }, // Wrapper that lays the custom scrollbar track + ScrollView side by // side inside the encyclopedia modal. explainScrollWrap: { flexDirection: 'row', marginTop: 4, marginBottom: 4, // Height is bounded inline via `useWindowDimensions` in the // PrivacyExplanationModal component — flex math doesn't survive // the sheetTall's content-sized parent chain on Android, so we // anchor the scroll region to a deterministic pixel cap there. }, explainScrollTrack: { width: 5, marginRight: 8, marginTop: 4, marginBottom: 4, borderRadius: 3, backgroundColor: Palette.bgElevated, overflow: 'hidden', }, explainScrollThumb: { width: 5, borderRadius: 3, backgroundColor: Palette.accentDeep, }, // Base ScrollView style used by the FlowEditorModal. Blends with the // FlowEditorModal form-body scroll. flexShrink (not flex) so the // ScrollView sizes to its content, and the parent sheetTall's // own flexShrink kicks in when content exceeds maxHeight: '88%' // — clipping + scrolling. flex:1 here breaks the sheet's height // negotiation through the KeyboardAvoidingView wrapper, collapsing // the form to 0 height. explainScroll: { flexShrink: 1, }, // Encyclopedia-specific override — lives inside `explainScrollWrap` // (a flex row), so it needs `flex: 1` to claim the remaining width // after the scrollbar track on the left. Margins are managed by the // wrapper, so they're zeroed here. explainScrollInWrap: { // No flex — sized to its content, capped by the inline maxHeight // applied at the call site (useWindowDimensions × percentage). // The earlier `flex: 1` made sense when this ScrollView sat // inside a flex-row wrap; as a direct child of the column-flex // sheetTall it collapsed to 0 height. // // Rounded, fully-bordered panel — reads as a distinct inset // "reading surface" within the sheet rather than a pair of bare // hairlines. overflow:hidden clips the scrolling content to the // rounded corners. marginTop: 2, marginBottom: 4, borderWidth: 1, borderColor: Palette.border, borderRadius: 14, backgroundColor: Palette.bg, overflow: 'hidden', }, explainScrollContent: { paddingVertical: 14, paddingHorizontal: 12, }, // Two-intent summary rows. Each intent renders as a soft card so // the supported/unsupported state is glanceable without relying // on hairline dividers. intentRow: { paddingVertical: 10, paddingHorizontal: 11, marginTop: 8, borderRadius: 10, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bgElevated, }, intentHeader: { flexDirection: 'row', alignItems: 'center', gap: 8 }, intentLabel: { flex: 1, fontSize: 13, color: Palette.text, fontWeight: '700', letterSpacing: 0.3, }, intentBadge: { fontSize: 10, color: Palette.accentBright, fontWeight: '700', letterSpacing: 0.5, textTransform: 'uppercase', }, intentDesc: { fontSize: 11, color: Palette.textMuted, lineHeight: 16, marginTop: 4, paddingLeft: 24, }, // Sections inside the encyclopedia. Each is a softly-grouped panel // with a small heading and tidy body — gives the modal a clearer // reading rhythm than the previous flat list of titled chunks. section: { marginBottom: 12, paddingVertical: 10, paddingHorizontal: 11, borderRadius: 10, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bgElevated, }, sectionTitle: { fontSize: 10, color: Palette.accentBright, fontWeight: '700', letterSpacing: 1, textTransform: 'uppercase', marginBottom: 6, }, // Standalone heading used outside the bordered sections (e.g. the // "Supported intents" label above the two intent cards). Matches // sectionTitle typography but without the panel chrome. sectionTitleSolo: { fontSize: 10, color: Palette.accentBright, fontWeight: '700', letterSpacing: 1, textTransform: 'uppercase', marginTop: 4, marginBottom: 2, paddingHorizontal: 2, }, bulletRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 8, paddingVertical: 2 }, bullet: { fontSize: 13, color: Palette.accentBright, lineHeight: 17, marginTop: -1 }, bulletText: { flex: 1, fontSize: 11, color: Palette.text, lineHeight: 17 }, scoreLine: { fontSize: 11, color: Palette.text }, scoreBig: { fontSize: 16, color: Palette.highlight, fontWeight: '700' }, scoreVersion: { fontSize: 10, color: Palette.textMuted, marginTop: 2, fontFamily: Fonts?.mono }, // Top-of-modal two-column score breakdown. scoreBreakdown: { flexDirection: 'row', gap: 10, marginBottom: 12, paddingBottom: 12, borderBottomWidth: 1, borderBottomColor: Palette.border, }, scoreCol: { flex: 1, gap: 6 }, // Hairline separating the Security / Privacy columns — gives the // two-up breakdown a cleaner seam than relying on the gap alone. scoreColDivider: { width: 1, alignSelf: 'stretch', backgroundColor: Palette.border, marginHorizontal: 2, }, scoreColHeader: { flexDirection: 'row', alignItems: 'center', gap: 5, marginBottom: 2 }, scoreColLabel: { fontSize: 10, color: Palette.accentBright, fontWeight: '700', letterSpacing: 0.8, textTransform: 'uppercase', flex: 1, }, scoreColTotal: { fontSize: 18, color: Palette.highlight, fontWeight: '700' }, scoreColScale: { fontSize: 10, color: Palette.textMuted, fontWeight: '600' }, // Bar-style component score row: label + value on top, 0–100 fill, // optional note below. scoreBarRow: { gap: 2, marginBottom: 6 }, scoreBarHeader: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'space-between', }, scoreBarLabel: { fontSize: 11, color: Palette.text, fontWeight: '600' }, scoreBarValue: { fontSize: 12, color: Palette.text, fontWeight: '700', fontVariant: ['tabular-nums'], }, scoreBarTrack: { height: 4, borderRadius: 2, backgroundColor: Palette.border, overflow: 'hidden', }, scoreBarFill: { height: 4, borderRadius: 2, }, scoreBarNote: { fontSize: 10, color: Palette.textMuted, lineHeight: 13, marginTop: 2 }, // privacytests.org per-category breakdown — label, mini-bar, ratio. testCategoryGrid: { gap: 4, marginTop: 8 }, testCategoryRow: { flexDirection: 'row', alignItems: 'center', gap: 8, }, testCategoryLabel: { width: 92, fontSize: 10, color: Palette.text, }, testCategoryTrack: { flex: 1, height: 4, borderRadius: 2, backgroundColor: Palette.border, overflow: 'hidden', }, testCategoryFill: { height: 4, borderRadius: 2, }, testCategoryRatio: { fontSize: 10, color: Palette.textMuted, fontVariant: ['tabular-nums'], width: 36, textAlign: 'right', }, scorePart: { flexDirection: 'row', gap: 8, alignItems: 'flex-start' }, scorePartScore: { fontSize: 13, color: Palette.text, fontWeight: '700', width: 28, textAlign: 'right', fontVariant: ['tabular-nums'], }, scorePartText: { flex: 1 }, scorePartLabel: { fontSize: 11, color: Palette.text, fontWeight: '600' }, scorePartNote: { fontSize: 10, color: Palette.textMuted, lineHeight: 13, marginTop: 1 }, }); export default function App() { return ( ); } const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: Palette.bg }, // Spans the ScrollView content so taps on empty regions fall // through to Keyboard.dismiss. flex:1 + width:100% so it claims // the full scrollable area on tall and short content alike. scrollDismissArea: { flex: 1, width: '100%' }, // flexGrow lets the inner centered-stack view claim leftover vertical // space when content is shorter than the viewport (so LINK/FLOWS/NEW // FLOW sit visually centered). When content overflows the viewport, // the ScrollView takes over and things scroll normally. content: { paddingHorizontal: 16, paddingBottom: 40, flexGrow: 1 }, // Center the whole block (LINK + FLOWS + NEW FLOW) vertically in // the leftover viewport height below the top bar. Tried split // top/bottom — the empty middle felt dead, so we cluster the two // groups together and let them float in the middle as one unit. // flex:1 + justifyContent center, paired with content's flexGrow:1 // on the ScrollView container. // Centered block for LINK + FLOWS + NEW FLOW. flex:1 + // justifyContent center claims the leftover viewport and groups // all three zones in the middle. Upward bias (paddingBottom) is // set inline because it varies with viewport height — see // centerBiasPadding in Home(). // flex-start so content sits high under the meta row without // percentage-padding gymnastics. Earlier we tried justifyContent // center + paddingBottom: '85%' to push content up, but the // percentage makes zoneStack self-expand to ~6.7× content height // (since the 15% non-padded area must fit all children), which // overflows the viewport and turns the ScrollView scrollable // even when content fits. flex-start is deterministic. zoneStack: { flex: 1, justifyContent: 'flex-start' }, // Semantic wrappers only — the parent's center axis groups them. // LINK section wrapper. relative positioning + a hair of padding // so the corner brackets (absolutely positioned children) sit just // outside the inner content rather than overlapping it. topGroup: { position: 'relative', paddingHorizontal: 8, paddingVertical: 6, // Extra air between the metaRow above (Settings / Default // Browser) and the LINK section scope — gives the navbar room // to breathe before the framed input region starts. marginTop: 18, }, // Corner brackets ("scope" marks) — each is an L-shape made of two // touching borders. Length 12dp, accentDeep stroke so they read as // structural rails, not decoration. scopeCorner: { position: 'absolute', width: 12, height: 12, borderColor: Palette.accentDeep, }, // Each L-shape gets a radius on its outer corner so the brackets // curl gently instead of meeting at a sharp 90°. Reads as a softer // "viewfinder" framing the LINK region. scopeCornerTL: { top: 0, left: 0, borderTopWidth: 1, borderLeftWidth: 1, borderTopLeftRadius: 6, }, scopeCornerTR: { top: 0, right: 0, borderTopWidth: 1, borderRightWidth: 1, borderTopRightRadius: 6, }, scopeCornerBL: { bottom: 0, left: 0, borderBottomWidth: 1, borderLeftWidth: 1, borderBottomLeftRadius: 6, }, scopeCornerBR: { bottom: 0, right: 0, borderBottomWidth: 1, borderRightWidth: 1, borderBottomRightRadius: 6, }, // FLOWS section wrapper — same scoping pattern as topGroup so the // two regions read as visually paired (each with its own corner // brackets). Top padding leaves room for the centered "Flows" // caption to sit on the top border without overlapping the list. bottomGroup: { position: 'relative', paddingHorizontal: 2, paddingVertical: 6, // Section gap is now provided by the rune divider above, so // the wrapper itself doesn't need extra top margin. }, // Centered caption that breaks the top bracket line — sits on the // border with the page bg painted behind it so the corner-bracket // strokes don't overlap the text. Reads as a frame plate. scopeTopLabelWrap: { position: 'absolute', top: -7, left: 0, right: 0, alignItems: 'center', }, scopeTopLabel: { backgroundColor: Palette.bg, paddingHorizontal: 8, fontSize: 10, color: Palette.textMuted, fontWeight: '700', letterSpacing: 1.5, textTransform: 'uppercase', }, topBar: { // One-row navbar: brand left, controls right, separated by // space-between. Hairline finishes the bottom edge. flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingBottom: 10, marginBottom: 8, borderBottomWidth: 1, borderBottomColor: Palette.border, }, // Settings shelf under the hairline — single left-aligned control. metaRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10, }, // CTA for the "make me default" state. Shield + text styled // identically to the Settings affordance next to it (same // textMuted tone, same weight, same icon size) so they read as // a matched pair of navbar controls. The only thing setting // this one apart is the glowing border — a quiet halo says // "act on this" without changing the typographic voice. setDefaultCtaGlow: { flexDirection: 'row', alignItems: 'center', gap: 5, paddingVertical: 4, paddingHorizontal: 8, borderRadius: 8, borderWidth: 1, borderColor: Palette.accent, boxShadow: '0 0 6px rgba(184,212,154,0.40)', }, setDefaultCtaGlowText: { fontSize: 12, color: Palette.textMuted, fontWeight: '600', }, brandTitleRow: { flexDirection: 'row', alignItems: 'center', gap: 8, }, // Small unobtrusive gear button that opens the Settings sheet. Lives // to the left of the default-browser pill so the user always has a // path to global preferences without needing a separate menu. // Icon + label — gear sits next to the default-status pill in // the navbar right cluster. Padding gives a generous press // target without needing a fixed square. // Bordered pill — same geometry (padding + radius + border // width) as the Set-as-Default CTA next to it so the two // buttons sit at identical height. Distinguishes via the // muted border colour + no glow. settingsIconBtn: { flexDirection: 'row', alignItems: 'center', gap: 5, paddingVertical: 4, paddingHorizontal: 8, borderRadius: 8, borderWidth: 2, borderColor: Palette.border, }, settingsIconLabel: { fontSize: 12, color: Palette.textMuted, fontWeight: '600', }, // Circle frame around the Dagaz glyph. Even size + integer // border so the ring stays a clean circle on Android. settingsRuneRing: { width: 22, height: 22, borderRadius: 11, borderWidth: 1, borderColor: Palette.textMuted, alignItems: 'center', justifyContent: 'center', }, settingsRuneGlyph: { fontSize: 13, color: Palette.textMuted, fontWeight: '500', // includeFontPadding off + lineHeight matches ring height so // the rune visually centers regardless of the font's intrinsic // baseline offset. lineHeight: 16, includeFontPadding: false, textAlignVertical: 'center', }, // Subtle "Default" indicator — same geometry as the ratingPill / urlActionPill // vocabulary used elsewhere. Not a CTA, just a quiet confirmation. defaultPill: { flexDirection: 'row', alignItems: 'center', gap: 4, paddingHorizontal: 8, paddingVertical: 3, borderRadius: 999, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bgElevated, }, defaultPillText: { fontSize: 11, color: Palette.accentBright, fontWeight: '700', letterSpacing: 0.3 }, brandRow: { flexDirection: 'row', alignItems: 'center', gap: 10 }, // Subtle press feedback so the whole title area reads as tappable // without adding a chrome that competes with the wordmark. brandRowPressed: { opacity: 0.6 }, brandMark: { width: 32, height: 32, // Lets the PNG scale crisply at any density — the source is 256×256 // (8x the displayed size). resizeMode: 'contain', }, brandText: { flexDirection: 'column', alignItems: 'flex-start' }, brandName: { fontFamily: Fonts?.serif, fontSize: 18, color: Palette.text, fontWeight: '500', letterSpacing: 0.2, }, brandNameAccent: { color: Palette.highlight, fontStyle: 'italic' }, // Slogan tucked between the wordmark and the version row. brandSlogan: { fontSize: 11, color: Palette.textMuted, fontStyle: 'italic', letterSpacing: 0.3, marginTop: 1, }, versionRow: { flexDirection: 'row', alignItems: 'center', gap: 6, opacity: 0.7, marginTop: 2 }, // Compact version tag tucked beneath the Warden wordmark in // the brand column. Small, muted, mono so it reads as metadata // rather than competing with the brand. brandVersionInline: { fontSize: 10, color: Palette.textMuted, fontFamily: Fonts?.mono, fontWeight: '600', marginTop: 1, opacity: 0.7, }, versionTag: { fontSize: 11, color: Palette.accentBright, fontFamily: Fonts?.mono, fontWeight: '600' }, versionDot: { fontSize: 11, color: Palette.textMuted }, versionCommit: { fontSize: 11, color: Palette.textMuted, fontFamily: Fonts?.mono }, // Tappable "About" link in the version row — replaces the freestanding // (i) info chip. Accent-coloured so the affordance is discoverable // without shouting. versionAboutLink: { fontSize: 11, color: Palette.accentBright, fontWeight: '600', textDecorationLine: 'underline', textDecorationColor: Palette.accentBright, }, kicker: { fontSize: 10, letterSpacing: 2, textTransform: 'uppercase', color: Palette.accentBright, fontWeight: '600' }, titleRow: { flexDirection: 'row', alignItems: 'center', marginTop: 4, marginBottom: 14, gap: 10, }, title: { fontFamily: Fonts?.serif, fontSize: 26, color: Palette.text, flex: 1, fontWeight: '400' }, em: { fontStyle: 'italic', color: Palette.highlight }, // Layout B (quiet rails) — small caps section marker that anchors // each zone (LINK, BROWSERS, NEW FLOW) along the left edge. // Margins kept tight because LINK sits inline with the tools row; // BROWSERS / NEW FLOW labels use a wrapper to add their own // breathing room below. zoneLabel: { fontSize: 9, color: Palette.accentBright, fontWeight: '700', letterSpacing: 1.5, }, // Wrapper for the LINK zone label when it sits inline with the // testsBtn (Presets) pill. Mirrors the pill's paddingVertical so // both flex children have identical box dimensions, which lets // `alignItems: 'center'` produce a clean visual alignment without // chasing Android's font-padding quirks. linkLabelBox: { paddingVertical: 3, paddingHorizontal: 0, alignItems: 'center', justifyContent: 'center', }, // Standalone zone label (FLOWS, NEW FLOW) — sits on its own line, // not inline with a sibling pill. Generous breathing room above // to separate from the previous zone + a small marginBottom so // the next element doesn't crowd the caps. zoneLabelStandalone: { marginTop: 30, marginBottom: 6, // Soft indent so the title clears the screen edge and reads as // a header rather than a hard-left caps row. Matches the // urlToolsPanel inset above for consistent left rhythm. marginLeft: 6, }, // Hairline horizontal divider used between zones. Asymmetric // margins: less above (the line above is the URL status which has // tight padding already), more below to push the next zone label // clear of the line. zoneDivider: { height: 1, backgroundColor: Palette.border, marginTop: 6, marginBottom: 12, }, // Tools row + URL field stripped of card chrome so the section // reads as one continuous zone. The field keeps a softly elevated // background + rounded border so it still presents as a clearly // tappable input box. urlToolsPanel: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', // Small left/right indent so the LINK label + Presets cluster // don't crash against the screen edge. Doesn't fully match the // URL-field's inner padding (12) but reads as "soft alignment" // without making the tools row feel pushed-in. paddingHorizontal: 6, // No top margin — the LINK section's corner brackets frame the // tools row directly, so any extra top space here would push // the content visually off-axis from the top brackets. marginTop: 0, marginBottom: 8, }, urlFieldPanel: { backgroundColor: Palette.bgElevated, borderWidth: 1, borderColor: Palette.border, borderRadius: 10, paddingVertical: 10, paddingHorizontal: 12, marginBottom: 6, }, urlHeaderLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 }, // Paste / Clear / Share share one container that claims the // remaining width to the right of LINK + Presets. flex-end pulls // the whole cluster to the right edge; the fixed gap keeps the // three pills tightly packed instead of spreading across half // the row. urlHeaderRightGroup: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', gap: 16, }, urlLabel: { fontSize: 9, letterSpacing: 1.5, color: Palette.accentBright, fontWeight: '600' }, testsBtn: { // Matches urlActionPill (Paste / Clear / Share) so all four // tools-row buttons share identical geometry: gap 3, padding 2/7, // pill radius, muted border, bgElevated fill. flexDirection: 'row', alignItems: 'center', gap: 3, paddingVertical: 2, paddingHorizontal: 7, borderRadius: 999, backgroundColor: Palette.bgElevated, borderWidth: 1, borderColor: Palette.border, }, testsBtnPressed: { opacity: 0.7 }, testsBtnText: { fontSize: 11, color: Palette.accentBright, fontWeight: '700' }, testsBox: { backgroundColor: Palette.surfaceDim, borderWidth: 1, borderColor: Palette.border, borderRadius: 10, paddingVertical: 8, paddingHorizontal: 10, marginBottom: 10, gap: 2, }, // Breathing room between groups in the Presets modal. presetGroupSpacer: { marginTop: 12 }, // ─── Inline Custom-presets section in PresetsModal ─────────────── // Bordered panel wrapping the entire Custom section. Same chrome // values as the group-card variant (testsRowGroup) so the nav group // and the user's custom list read as visual peers. presetCustomPanel: { marginTop: 10, paddingVertical: 10, paddingHorizontal: 10, borderRadius: 10, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bgElevated, }, // Small lowercase hint sitting just under the caps "CUSTOM" header // — surfaces the long-press affordance without crowding the row. // Empty-state CTA shown in place of the row list when the user has // no custom URLs yet. Centered + larger than the header chip so // there's one obvious thing to do. presetCustomEmptyCta: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, alignSelf: 'center', marginTop: 6, marginBottom: 4, paddingVertical: 8, paddingHorizontal: 16, borderRadius: 999, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bgElevated, }, presetCustomEmptyCtaText: { fontSize: 12, color: Palette.highlight, fontWeight: '700', letterSpacing: 0.3, }, // Quiet long-press affordance hint shown only when there's at least // one custom row to actually long-press on. presetCustomFootHint: { fontSize: 10, color: Palette.textMuted, fontStyle: 'italic', paddingHorizontal: 4, marginTop: 6, textAlign: 'center', }, presetCustomAddRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: 6, paddingVertical: 7, paddingLeft: 12, paddingRight: 6, borderRadius: 9, borderWidth: 1, borderStyle: 'dashed', borderColor: Palette.border, backgroundColor: Palette.bg, }, presetCustomInput: { flex: 1, fontSize: 12, color: Palette.text, paddingVertical: 0, paddingHorizontal: 0, }, presetCustomAddBtn: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: 14, borderWidth: 1, borderColor: Palette.highlight, backgroundColor: Palette.bgElevated, }, presetCustomError: { fontSize: 11, color: '#d4a04a', fontStyle: 'italic', marginTop: 6, paddingHorizontal: 4, }, // Custom-panel header row — anchors the Add URL chip to the right // edge of the panel when the composer isn't active. (The "Custom" // caps label was dropped now that the tab strip carries the same // word.) presetCustomHeaderRow: { flexDirection: 'row', justifyContent: 'flex-end', marginBottom: 4, }, // Compact "+ Add URL" chip pinned to the right of the Custom panel // header. Tap-to-expand reveals the composer below the items. presetCustomAddChip: { flexDirection: 'row', alignItems: 'center', gap: 4, paddingHorizontal: 8, paddingVertical: 4, borderRadius: 999, borderWidth: 1, borderColor: Palette.highlight, backgroundColor: Palette.bgElevated, }, presetCustomAddChipText: { fontSize: 10, color: Palette.highlight, fontWeight: '700', letterSpacing: 0.5, textTransform: 'uppercase', }, testsBoxLabel: { fontSize: 10, color: Palette.textMuted, letterSpacing: 0.5, marginBottom: 4, paddingHorizontal: 4, textTransform: 'uppercase', marginTop: 6, }, testsDivider: { height: 1, backgroundColor: Palette.border, marginVertical: 4, }, presetsScroll: { maxHeight: 380, marginTop: 6, }, presetsScrollContent: { paddingVertical: 10, paddingHorizontal: 10, }, testsRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, paddingHorizontal: 10, marginVertical: 2, borderRadius: 8, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bg, gap: 10, }, testsRowPressed: { backgroundColor: Palette.surface }, // Forward-nav row variant — used by group rows that drill into a // sub-list. Bordered + elevated bg signals "tappable card with more // inside" rather than a flat list item. testsRowGroup: { paddingVertical: 11, paddingHorizontal: 12, borderRadius: 10, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bgElevated, marginTop: 4, }, presetIcon: { width: 16 }, testsRowText: { flex: 1 }, testsRowLabel: { fontSize: 13, color: Palette.text, fontWeight: '500' }, testsRowHint: { fontSize: 11, color: Palette.textMuted, marginTop: 1 }, urlActions: { flexDirection: 'row', gap: 6 }, iconBtn: { width: 28, height: 28, borderRadius: 8, alignItems: 'center', justifyContent: 'center', backgroundColor: Palette.bgElevated, }, iconBtnPressed: { opacity: 0.7, transform: [{ scale: 0.95 }] }, iconBtnDisabled: { opacity: 0.35 }, // Pill style for the URL field's Clear / Paste / Share actions. Matches // the `ratingPill` "browser checks" geometry — rounded 999, hairline // border, elevated bg, tiny icon + tiny text — so the three sit // visually alongside the score pills as the same vocabulary. urlActionPill: { flexDirection: 'row', alignItems: 'center', gap: 3, paddingHorizontal: 7, paddingVertical: 2, borderRadius: 999, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bgElevated, }, urlActionPillText: { fontSize: 11, color: Palette.accentBright, fontWeight: '700' }, // Applied to BOTH the Share Source pill (URL tools row) and the // Share Final pill (status row) so the two mirror each other — // identical width + center-anchored content. The wider of the two // labels ("Share Source" at 12 chars) drives the minWidth; the // other pill stretches to match. // Yellow-glow "you need to do something" CTA. Only rendered when Warden // is NOT the default browser. The yellow (`#f6c84c`) is the same hue we // use for the Autolaunch indicator, so attention-grabbing markers in the // UI share a vocabulary. boxShadow gives the actual halo on RN 0.76+ // (works on Android via the new arch); textShadow reinforces the glow on // the label itself for devices where boxShadow lands subtly. setDefaultCta: { flexDirection: 'row', alignItems: 'center', paddingVertical: 6, paddingHorizontal: 12, borderRadius: 999, borderWidth: 1.5, borderColor: '#f6c84c', backgroundColor: 'rgba(246, 200, 76, 0.08)', // @ts-ignore — boxShadow is supported on RN 0.76+ but the type ships later. boxShadow: '0 0 12px rgba(246, 200, 76, 0.55)', }, setDefaultCtaText: { fontSize: 12, color: '#f6c84c', fontWeight: '700', letterSpacing: 0.3, textShadowColor: 'rgba(246, 200, 76, 0.75)', textShadowOffset: { width: 0, height: 0 }, textShadowRadius: 6, }, statusPressed: { opacity: 0.7, transform: [{ scale: 0.97 }] }, urlText: { fontSize: 13, color: Palette.text, fontFamily: Fonts?.mono }, urlInput: { fontSize: 13, color: Palette.text, fontFamily: Fonts?.mono, padding: 0, minHeight: 20 }, // Shown in place of the URL field when the Browser-config preset is // active. Reads as a tag — the user can't edit because each Flow has // its own browser-specific URL surfaced on the right side of the row. urlConfigBanner: { flexDirection: 'row', alignItems: 'center', gap: 7, paddingVertical: 5, }, urlConfigBannerText: { fontSize: 12, color: Palette.textMuted, fontStyle: 'italic', flex: 1, }, // Generic press feedback for tappable rows that aren't part of a dedicated // button style (e.g. the New-Flow button). tabPressed: { opacity: 0.7 }, emptyHint: { fontSize: 12, color: Palette.textMuted, textAlign: 'center', paddingVertical: 14, letterSpacing: 0.5, fontStyle: 'italic', }, // Softly glowing card — solid sage-tinted border + outer halo so // the empty state reads as inviting / "something is about to // happen here" rather than a dashed dropzone. onboardingCard: { alignItems: 'center', paddingVertical: 24, paddingHorizontal: 18, gap: 8, borderWidth: 1, borderColor: Palette.accentDeep, borderRadius: 14, backgroundColor: Palette.surfaceDim, boxShadow: '0 0 18px rgba(184,212,154,0.22)', }, // Large rune glyph carries its own halo — the sage textShadow // pushes the brand's mystical / inlay aesthetic without dragging // in any actual graphics. onboardingRune: { fontSize: 32, color: Palette.highlight, fontWeight: '500', letterSpacing: 2, lineHeight: 38, textShadowColor: 'rgba(184,212,154,0.55)', textShadowOffset: { width: 0, height: 0 }, textShadowRadius: 10, }, onboardingTitle: { fontSize: 14, color: Palette.text, fontWeight: '700', letterSpacing: 0.3, }, onboardingBody: { fontSize: 12, color: Palette.textMuted, lineHeight: 17, textAlign: 'center', }, onboardingCardPressed: { opacity: 0.75 }, // Breathing room above the New Flow button now that the NEW FLOW // zone marker has been removed. Mirrors the old marginTop the // label carried, so the visual rhythm between Flows list and the // button stays the same. newProfileBtnAfterFlows: { marginTop: 28 }, // Deliberately small. The big tap targets on this screen are the // per-browser launch buttons; both New Flow (rare config) and // Share URL (rare outbound) are kept compact and spread to opposite // edges of their row so they don't compete for thumb real estate. bottomActionRow: { position: 'relative', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 5, paddingVertical: 4, }, // Rune divider between FLOWS section and bottom action row. The // two hairlines flank a small "ᛟ ᛉ ᛟ" carving — algiz (protection, // the closest rune to a shield motif) framed by othala. Drawn in // accentDeep so it reads as quiet inlay, not flashy ornament. runeDivider: { flexDirection: 'row', alignItems: 'center', marginVertical: 22, paddingHorizontal: 12, }, // Standalone variant — single centered glyph, no flanking rails. // Used for the quieter Settings ↔ LINK divider. runeDividerSolo: { alignItems: 'center', marginVertical: 10, }, // Equal flex:1 on both flanks centers the rune glyph between them. runeDividerLine: { flex: 1, height: 1, backgroundColor: Palette.accentDeep, opacity: 0.5, }, runeDividerGlyph: { marginHorizontal: 14, color: Palette.accentDeep, fontSize: 14, letterSpacing: 2, fontWeight: '500', textAlign: 'center', }, newProfileBtn: { flexDirection: 'row', alignItems: 'center', gap: 5, paddingVertical: 6, paddingHorizontal: 10, borderRadius: 8, borderWidth: 1, // Muted border — secondary affordance, doesn't steal attention // from the per-browser Open buttons above. borderColor: Palette.border, backgroundColor: Palette.bgElevated, }, newProfileBtnText: { fontSize: 11, color: Palette.accentBright, fontWeight: '700', letterSpacing: 0.3, }, errorBox: { backgroundColor: '#3a1a1a', borderWidth: 1, borderColor: '#8a3a3a', borderRadius: 10, paddingVertical: 10, paddingHorizontal: 12, marginBottom: 10, }, errorText: { fontSize: 12, color: '#f0a0a0', lineHeight: 16 }, list: { gap: 12 }, // Tightened gap between the LINK section and the flows list now // that the FLOWS zone marker has been dropped — just enough air // to read as a section break, not a full chapter divider. // Old marginTop:28 used to separate the flow list from the LINK // section above. The gap now lives on bottomGroup (marginTop) so // it's outside the corner brackets; this style is kept as a small // breath between the "Flows" caption and the first row. flowsListAfterLink: { marginTop: 4 }, // Profile tab selector profileTabs: { flexDirection: 'row', gap: 4, marginBottom: 10, paddingHorizontal: 2, }, profileTab: { flexDirection: 'row', alignItems: 'center', gap: 5, paddingVertical: 5, paddingHorizontal: 10, borderRadius: 8, borderWidth: 1, borderColor: Palette.border, backgroundColor: 'transparent', }, profileTabActive: { borderColor: Palette.accent, backgroundColor: Palette.accent + '18', }, profileTabText: { fontSize: 11, fontWeight: '600', color: Palette.textMuted, letterSpacing: 0.2, }, profileTabTextActive: { color: Palette.accentBright, }, profileTabCount: { minWidth: 16, paddingVertical: 1, paddingHorizontal: 4, borderRadius: 8, backgroundColor: Palette.bgElevated, alignItems: 'center', }, profileTabCountActive: { backgroundColor: Palette.accent + '30', }, profileTabCountText: { fontSize: 10, fontWeight: '700', color: Palette.textMuted, }, profileTabCountTextActive: { color: Palette.accentBright, }, // Profile badge dot on flow rows profileBadge: { width: 6, height: 6, borderRadius: 3, marginRight: 6, }, profileBadgeRaw: { backgroundColor: Palette.textMuted, }, profileBadgeWork: { backgroundColor: '#f6c84c', }, // Flow rows are split: left = title/meta (opens editor), right = launch. rowOuter: { flexDirection: 'row', alignItems: 'stretch', backgroundColor: Palette.surface, borderWidth: 1, borderColor: Palette.border, borderRadius: 12, overflow: 'hidden', }, rowLeft: { flex: 1, flexDirection: 'row', alignItems: 'center', paddingVertical: 3, paddingHorizontal: 6, }, rowRight: { // Opening part — meatier tap target. On a ~360dp phone width: // 130 lands ~36% of total row width, leaving the browser-info // side ~64%. width: 130, gap: 3, alignItems: 'center', justifyContent: 'center', // Stronger divider so the two halves read as distinct interactive // zones (left = edit, right = launch). Doubled stroke + bumped color // from `border` to `accentDeep` so it actually shows against the // surface tone; the right half's bg is also bumped one step darker. borderLeftWidth: 2, borderLeftColor: Palette.accentDeep, backgroundColor: Palette.bg, }, rowRightLabel: { fontSize: 10, fontWeight: '700', letterSpacing: 1.2, textTransform: 'uppercase', color: Palette.accentBright, textAlign: 'center', lineHeight: 12, }, rowPressed: { opacity: 0.85, transform: [{ scale: 0.99 }] }, rowDisabled: { opacity: 0.35 }, swatch: { width: 28, height: 28, borderRadius: 8, marginRight: 10 }, icon: { width: 32, height: 32, borderRadius: 8, marginRight: 10, resizeMode: 'contain' }, rowText: { flex: 1 }, rowName: { flexShrink: 1, fontSize: 13, color: Palette.text, fontWeight: '500' }, rowTag: { fontSize: 11, color: Palette.textMuted, marginTop: 1 }, alwaysPrivateRow: { flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 2 }, alwaysPrivateIcon: { fontSize: 11 }, alwaysPrivateLabel: { fontSize: 10, color: Palette.highlight, letterSpacing: 0.8, fontWeight: '600', textTransform: 'uppercase', }, rowNameRow: { flexDirection: 'row', alignItems: 'center', gap: 6, flexWrap: 'wrap' }, // Free-form Tag pill next to the browser name. Same ratingPill / // urlActionPill vocabulary: rounded 999, hairline border, elevated bg. flowTagPill: { flexShrink: 1, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 999, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bgElevated, }, flowTagPillText: { fontSize: 11, color: Palette.accentBright, fontWeight: '600', letterSpacing: 0.2, }, autolaunchBadge: { flexShrink: 0, width: 20, height: 20, alignItems: 'center', justifyContent: 'center', borderRadius: 999, borderWidth: 1, borderColor: '#f6c84c', backgroundColor: 'transparent', }, modePillRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 3, marginTop: 3 }, // Active modes render as plain uppercase labels joined by middot // separators — no pill chrome, just text. Reads as a sentence-y // "privacy profile" description rather than a row of tags. modeInlineText: { fontSize: 8, color: Palette.highlight, fontWeight: '700', letterSpacing: 0.6, textTransform: 'uppercase', alignSelf: 'center', }, modeInlineSep: { color: Palette.accentDeep, fontWeight: '400', }, // Same shape as modeInlineText, but muted — used for the "Normal" // resting state so it reads as secondary while staying flush-left // with the browser name above (no pill chrome to inset it). modeInlineTextMuted: { fontSize: 8, color: Palette.textMuted, fontWeight: '700', letterSpacing: 0.6, textTransform: 'uppercase', alignSelf: 'center', }, modePill: { flexDirection: 'row', alignItems: 'center', gap: 2, paddingHorizontal: 5, paddingVertical: 1, borderRadius: 999, borderWidth: 1, borderColor: Palette.highlight, backgroundColor: 'transparent', }, modePillText: { fontSize: 8, color: Palette.highlight, fontWeight: '700', letterSpacing: 0.3, textTransform: 'uppercase', }, modePillMuted: { paddingHorizontal: 5, paddingVertical: 1, borderRadius: 999, borderWidth: 1, borderColor: Palette.border, backgroundColor: 'transparent', }, modePillTextMuted: { fontSize: 8, color: Palette.textMuted, fontWeight: '700', letterSpacing: 0.3, textTransform: 'uppercase', }, // Status column: two stacked lines. // - Top line (urlHintStatusLine) — state label on the left, cycle + // Share Final actions on the right. // - Bottom line — link icon + final-URL text, full width of the // column so long URLs can expand without competing with buttons. urlHintCol: { flexDirection: 'column', paddingTop: 4, paddingHorizontal: 2, }, urlHintStatusLine: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 6, }, urlHintActions: { flexDirection: 'row', alignItems: 'center', gap: 6, }, urlHintText: { flex: 1, fontSize: 11, color: Palette.textMuted, lineHeight: 15 }, // Single line inside the status panel — pairs an icon with its // text so they share a row and stay vertically aligned via the // row's `alignItems: 'center'`. urlHintLine: { flexDirection: 'row', alignItems: 'center', gap: 6, }, // Press feedback for the link line — fires when detailPress is // defined, so the tap reads as actually doing something. urlHintLinePressed: { opacity: 0.55 }, // Hidden state for the link line — content stays in the layout // (preserving the panel's height) but is fully transparent. Pair // with `disabled` on the Pressable so taps don't fire. urlHintLineReserved: { opacity: 0 }, urlHintProxiedLabel: { fontSize: 8, color: Palette.highlight, fontWeight: '700', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 1, }, // Small reload glyph anchored to the right of the URL status row. // Status-row "Share Final" pill — matches the urlActionPill // geometry used by Share / Paste / Clear up in the SOURCE row // (gap 3, padding 2/7, pill radius, muted border, bgElevated). urlHintShareBtn: { flexDirection: 'row', alignItems: 'center', gap: 3, paddingVertical: 2, paddingHorizontal: 7, borderRadius: 999, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bgElevated, alignSelf: 'center', }, urlHintShareBtnText: { fontSize: 11, color: Palette.accentBright, fontWeight: '700', }, // Disabled-state for the status-row Share button — keeps the // glyph in place so the row geometry doesn't reflow, but reads // as inert. urlHintShareDisabled: { opacity: 0.35 }, // Shown only when the matched proxy destination has randomise on + // "Proxied" + cycle pill share one Pressable so tapping either // text or icon cycles. Tight inline grouping — both members read // as one affordance, with the trailing " · Stripped …" piece // sitting visibly outside. urlHintProxiedGroup: { flexDirection: 'row', alignItems: 'center', gap: 6, }, // The actual launchable URL — slightly brighter than the muted hint // copy so it reads as "the thing Warden will send" while still // sitting in the same panel chrome. urlHintLaunch: { color: Palette.text, fontFamily: Fonts?.mono, }, // Painted over the host portion of the displayed final URL when a // proxy rewrite ran — makes the rewritten host pop visually so the // user can see exactly what part of the link Warden changed. urlHintProxiedHost: { color: '#d6b75a', fontWeight: '700', }, // Quiet placeholder shown next to the link icon while no // parseable URL has landed. Dimmer than textMuted so it reads // as "absence" rather than "error" — the status caps line above // already carries the error message. urlHintPlaceholder: { color: Palette.textMuted, opacity: 0.7, fontSize: 11, }, // Small annotation when tracking params were removed. urlHintStripped: { color: Palette.accentBright, fontWeight: '600', }, // Negative marginLeft offsets the first pill's internal padding // (paddingHorizontal: 7 + 1px border) so the leading shield icon // visually lines up with the browser-name baseline above, instead // of sitting indented by the pill chrome. ratingsRow: { flexDirection: 'row', gap: 6, marginTop: 5, marginLeft: -8 }, ratingPill: { flexDirection: 'row', alignItems: 'center', gap: 3, paddingHorizontal: 7, paddingVertical: 2, borderRadius: 999, borderWidth: 1, borderColor: Palette.border, backgroundColor: Palette.bgElevated, }, ratingScore: { fontSize: 11, color: Palette.text, fontWeight: '700' }, ratingScale: { fontSize: 9, color: Palette.textMuted, fontWeight: '600' }, chev: { fontSize: 22, color: Palette.textMuted, marginLeft: 6 }, unknownBox: { marginTop: 14, backgroundColor: Palette.surfaceDim, borderWidth: 1, borderColor: Palette.border, borderRadius: 10, paddingVertical: 10, paddingHorizontal: 12, }, unknownLabel: { fontSize: 9, letterSpacing: 1.5, color: Palette.textMuted, fontWeight: '700', marginBottom: 4 }, unknownHint: { fontSize: 11, color: Palette.textMuted, marginBottom: 6 }, unknownPkg: { fontSize: 11, color: Palette.cream, fontFamily: Fonts?.mono, paddingVertical: 1 }, });