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<boolean>(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 (
    <View
      style={{
        width: size,
        height: size,
        borderRadius: half,
        overflow: 'hidden',
        borderWidth: thickness,
        borderColor: color,
        alignItems: 'center',
        justifyContent: 'center',
      }}>
      {/* Tinted quarters — top-left + bottom-right */}
      <View
        style={{
          position: 'absolute',
          left: 0, top: 0,
          width: half, height: half,
          backgroundColor: color,
          opacity: 0.18,
        }}
      />
      <View
        style={{
          position: 'absolute',
          left: half, top: half,
          width: half, height: half,
          backgroundColor: color,
          opacity: 0.18,
        }}
      />
      {/* Cross — clean integer positions */}
      <View
        style={{
          position: 'absolute',
          left: lineInset,
          top: half - thickness / 2,
          width: lineLen,
          height: thickness,
          backgroundColor: color,
        }}
      />
      <View
        style={{
          position: 'absolute',
          left: half - thickness / 2,
          top: lineInset,
          width: thickness,
          height: lineLen,
          backgroundColor: color,
        }}
      />
      <View
        style={{
          width: bossPx,
          height: bossPx,
          borderRadius: bossPx / 2,
          backgroundColor: color,
        }}
      />
    </View>
  );
}

/**
 * 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 (
    <View
      style={{
        position: 'absolute',
        left: cx - length / 2,
        top: cy - thickness / 2,
        width: length,
        height: thickness,
        backgroundColor: color,
        transform: [{ rotate: `${angleDeg}deg` }],
      }}
    />
  );
}

/**
 * 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 (
    <View
      style={{
        width: size,
        height: size,
        borderRadius: size / 2,
        borderWidth: thickness,
        borderColor: color,
      }}>
      {lines.map((seg, i) => (
        <IconLine
          key={i}
          size={size}
          color={color}
          thickness={thickness}
          from={seg[0]}
          to={seg[1]}
        />
      ))}
    </View>
  );
}

/**
 * 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 (
    <View
      style={{
        width: size,
        height: size,
        borderRadius: size / 2,
        borderWidth: thickness,
        borderColor: color,
        alignItems: 'center',
        justifyContent: 'center',
      }}>
      {studs.map(([sx, sy], i) => {
        const left = ((sx + 50) * size) / 100 - studPx / 2;
        const top = ((sy + 50) * size) / 100 - studPx / 2;
        return (
          <View
            key={i}
            style={{
              position: 'absolute',
              left,
              top,
              width: studPx,
              height: studPx,
              borderRadius: studPx / 2,
              backgroundColor: color,
            }}
          />
        );
      })}
      <View
        style={{
          width: bossPx,
          height: bossPx,
          borderRadius: bossPx / 2,
          backgroundColor: color,
        }}
      />
    </View>
  );
}

/**
 * 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 (
    <View
      style={{
        width: size,
        height: size,
        borderRadius: size / 2,
        borderWidth: spokeThickness,
        borderColor: color,
        alignItems: 'center',
        justifyContent: 'center',
      }}>
      {spokeAngles.map((angle) => (
        <View
          key={angle}
          style={{
            position: 'absolute',
            width: size - spokeThickness * 2,
            height: spokeThickness,
            backgroundColor: color,
            transform: [{ rotate: `${angle}deg` }],
          }}
        />
      ))}
      <View
        style={{
          width: bossSize,
          height: bossSize,
          borderRadius: bossSize / 2,
          backgroundColor: color,
        }}
      />
    </View>
  );
}

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 <Text> 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 (
    <View style={{ flexDirection: 'row', alignItems: 'flex-end' }}>
      {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 (
          <Animated.Text
            key={i}
            style={[style, { color }]}>
            {char}
          </Animated.Text>
        );
      })}
    </View>
  );
}

/**
 * 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<string, number> = {
  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<string, string> = {
  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<string, { security?: boolean; privacy?: boolean }> = {
  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<string, boolean> = {
  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<string, AvailabilityNote> = {
  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<string> = 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<Set<string>>(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<boolean>(false);
  const [errorMsg, setErrorMsg] = useState<string | null>(null);
  const [showPresets, setShowPresets] = useState(false);
  const [icons, setIcons] = useState<Record<string, string>>({});

  // 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<Flow[]>(() => loadFlows());
  const persistFlows = (next: Flow[]) => {
    _setFlows(next);
    saveFlows(next);
  };
  // The flow currently being edited (or freshly created). null = modal closed.
  const [editingFlow, setEditingFlow] = useState<Flow | null>(null);
  // Long-press action sheet target. null = sheet closed.
  const [actionFlow, setActionFlow] = useState<Flow | null>(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<Flow | null>(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<Profile>('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<boolean>(() =>
    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<boolean>(() =>
    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<boolean>(() =>
    getBoolPref(PREF_SHOW_PASTE, false),
  );
  const updateShowPaste = (next: boolean) => {
    setShowPaste(next);
    setBoolPref(PREF_SHOW_PASTE, next);
  };
  const [showClear, setShowClear] = useState<boolean>(() =>
    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<ProxyConfig>(() =>
    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<string[]>(() =>
    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<string | null>(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<string | null>(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<string, string> = { ...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<boolean>((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 (
    <RunicNamesContext.Provider value={runicNames}>
    <ScrollView
      style={styles.root}
      contentContainerStyle={[styles.content, { paddingTop: insets.top + 14 }]}
      keyboardShouldPersistTaps="handled"
      >
      {/* 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. */}
      <View style={styles.topBar}>
        {/* 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. */}
        <View style={styles.brandRow}>
          <Image
            source={require('./assets/images/warden-mark.png')}
            style={styles.brandMark}
            accessibilityIgnoresInvertColors
          />
          <View style={styles.brandText}>
            {/* Animated WardenWordmark disabled for now — swap back
                to <WardenWordmark style={styles.brandName} /> to
                re-enable the moving forest gradient. */}
            <Text style={[styles.brandName, { color: Palette.highlight }]}>
              {APP_NAME}
            </Text>
            <Text style={styles.brandSlogan}>The Browser-Link Guardian.</Text>
            <View style={styles.versionRow}>
              <Text style={styles.brandVersionInline}>v{BUILD_VERSION}</Text>
              <Text style={styles.versionDot}>·</Text>
              <Pressable
                onPress={() => setAboutOpen(true)}
                hitSlop={6}
                accessibilityLabel="About"
                accessibilityRole="button">
                <Text style={styles.versionAboutLink}>About</Text>
              </Pressable>
              <Text style={styles.versionDot}>·</Text>
              <Pressable
                onPress={() => setDonateOpen(true)}
                hitSlop={6}
                accessibilityLabel="Donate"
                accessibilityRole="button">
                <Text style={styles.versionAboutLink}>Donate</Text>
              </Pressable>
            </View>
          </View>
        </View>
      </View>

      {/* Shelf under the hairline: Settings left, default-browser
          status right. */}
      <View style={styles.metaRow}>
        <Pressable
          onPress={() => setSettingsOpen(true)}
          hitSlop={8}
          accessibilityLabel="Settings"
          style={({ pressed }) => [
            styles.settingsIconBtn,
            pressed && styles.statusPressed,
          ]}>
          <Ionicons name="settings-outline" size={16} color={Palette.textMuted} />
          <Text style={styles.settingsIconLabel}>Settings</Text>
        </Pressable>
        {/* 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 ? (
          <Pressable
            onPress={openDefaultAppsSettings}
            style={({ pressed }) => [
              styles.setDefaultCtaGlow,
              pressed && styles.statusPressed,
            ]}>
            <Ionicons name="shield-outline" size={16} color={Palette.textMuted} />
            <Text style={styles.setDefaultCtaGlowText}>Make Default</Text>
          </Pressable>
        ) : null}
      </View>


      <View style={styles.zoneStack}>
      <View style={styles.topGroup}>
      {/* 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. */}
      <View style={styles.urlToolsPanel}>
        {/* Left cluster: LINK label + Presets. */}
        <View style={styles.urlHeaderLeft}>
          <View style={styles.linkLabelBox}>
            <Text style={styles.zoneLabel}>SOURCE</Text>
          </View>
          <Pressable
            onPress={() => setShowPresets(!showPresets)}
            hitSlop={6}
            style={({ pressed }) => [styles.testsBtn, pressed && styles.testsBtnPressed]}>
            <Text style={styles.testsBtnText}>Presets</Text>
            <Ionicons
              name="chevron-forward"
              size={12}
              color={Palette.accentBright}
            />
          </Pressable>
        </View>
        {/* 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. */}
        <View style={styles.urlHeaderRightGroup}>
          {showPaste ? (
            <Pressable
              onPress={handlePaste}
              hitSlop={10}
              style={({ pressed }) => [styles.urlActionPill, pressed && styles.iconBtnPressed]}
              accessibilityLabel="Paste from clipboard">
              <Ionicons name="clipboard-outline" size={11} color={Palette.accentBright} />
              <Text style={styles.urlActionPillText}>Paste</Text>
            </Pressable>
          ) : null}
          {showClear ? (
            <Pressable
              onPress={() => {
                setTarget('');
                setErrorMsg(null);
                lastConsumed.current = null;
              }}
              hitSlop={10}
              style={({ pressed }) => [styles.urlActionPill, pressed && styles.iconBtnPressed]}
              accessibilityLabel="Clear URL">
              <MaterialCommunityIcons name="broom" size={11} color={Palette.accentBright} />
              <Text style={styles.urlActionPillText}>Clear</Text>
            </Pressable>
          ) : null}
          <Pressable
            onPress={handleShare}
            hitSlop={10}
            style={({ pressed }) => [styles.urlActionPill, pressed && styles.iconBtnPressed]}
            accessibilityLabel="Share this link">
            <Ionicons name="share-social-outline" size={11} color={Palette.accentBright} />
            <Text style={styles.urlActionPillText}>Source</Text>
          </Pressable>
        </View>
      </View>

      {/* 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. */}
      <View style={styles.urlFieldPanel}>
        {target === BROWSER_CONFIG_SENTINEL ? (
          <View style={styles.urlConfigBanner}>
            <Ionicons name="construct-outline" size={14} color={Palette.accentBright} />
            <Text style={styles.urlConfigBannerText}>
              Copies the config URL · opens the browser to paste it
            </Text>
          </View>
        ) : (
          <TextInput
            value={target}
            onChangeText={setTarget}
            style={styles.urlInput}
            multiline
            autoCapitalize="none"
            autoCorrect={false}
            keyboardType="url"
            selectionColor={Palette.highlight}
            placeholderTextColor={Palette.textMuted}
            placeholder=""
          />
        )}
      </View>

      <PresetsModal
        visible={showPresets}
        customPresets={customPresets}
        onSetCustomPresets={updateCustomPresets}
        onClose={() => 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 = (
          <Text style={styles.urlHintPlaceholder}>Awaiting URL</Text>
        );
        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 = <Text style={styles.urlHintLaunch}>{CONFIG_PLACEHOLDER}</Text>;
        } else if (isFlagLinkUrl(trimmed)) {
          iconName = 'share-outline';
          iconColor = Palette.highlight;
          labelColor = Palette.highlight;
          status = 'Flag URL · shared on Open';
          detailText = <Text style={styles.urlHintLaunch}>{trimmed}</Text>;
        } 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 = (
              <Text style={styles.urlHintPlaceholder}>Same URL</Text>
            );
          } 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 = (
                  <Text style={styles.urlHintLaunch}>
                    {before}
                    <Text style={styles.urlHintProxiedHost}>{host}</Text>
                    {after}
                  </Text>
                );
              } else {
                detailText = <Text style={styles.urlHintLaunch}>{displayUrl}</Text>;
              }
            } else {
              detailText = <Text style={styles.urlHintLaunch}>{displayUrl}</Text>;
            }
          }
          // 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 (
          <View style={styles.urlHintCol}>
            {/* 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. */}
            <View style={styles.urlHintStatusLine}>
              {(() => {
                // 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 (
                  <View style={styles.urlHintLine}>
                    <Ionicons name={iconName} size={11} color={iconColor} />
                    {hasProxied ? (
                      <>
                        {/* "Proxied" + cycle pill together form the
                            cycle affordance — one Pressable wraps
                            both so tapping the word or the icon both
                            advance the cycle. */}
                        <Pressable
                          onPress={() => setProxyCycleIndex((i) => i + 1)}
                          hitSlop={8}
                          accessibilityLabel="Cycle to next proxy hostname"
                          style={({ pressed }) => [
                            styles.urlHintProxiedGroup,
                            pressed && styles.urlHintLinePressed,
                          ]}>
                          <Text style={[styles.urlHintProxiedLabel, { color: labelColor }]}>
                            Proxied
                          </Text>
                          <Ionicons
                            name="sync-outline"
                            size={12}
                            color={Palette.highlight}
                          />
                        </Pressable>
                        {restText ? (
                          <Text style={[styles.urlHintProxiedLabel, { color: labelColor }]}>
                            {` · ${restText}`}
                          </Text>
                        ) : null}
                      </>
                    ) : (
                      <Text style={[styles.urlHintProxiedLabel, { color: labelColor }]}>
                        {status}
                      </Text>
                    )}
                  </View>
                );
              })()}
              <View style={styles.urlHintActions}>
                <Pressable
                  onPress={async () => {
                    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,
                  ]}>
                  <Ionicons
                    name="share-social-outline"
                    size={11}
                    color={launchPreview?.url ? Palette.accentBright : Palette.textMuted}
                  />
                  <Text
                    style={[
                      styles.urlHintShareBtnText,
                      !launchPreview?.url && { color: Palette.textMuted },
                    ]}>
                    Final
                  </Text>
                </Pressable>
              </View>
            </View>
            {/* 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. */}
            <Pressable
              onPress={detailPress}
              onLongPress={detailLongPress}
              disabled={!detailPress}
              style={({ pressed }) => [
                styles.urlHintLine,
                detailPress && pressed && styles.urlHintLinePressed,
              ]}>
              <Ionicons
                name="arrow-forward"
                size={11}
                color={Palette.textMuted}
              />
              <Text style={styles.urlHintText}>
                {detailText}
              </Text>
            </Pressable>
          </View>
        );
      })() : null}
      </View>

      {/* 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. */}
      <View style={[styles.runeDivider, { marginTop: 12 }]}>
        <View style={styles.runeDividerLine} />
        <Text style={styles.runeDividerGlyph}>ᛉ  ᛟ  ᛉ</Text>
        <View style={styles.runeDividerLine} />
      </View>

      <View style={styles.bottomGroup}>
      {/* 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 */}
      <View style={styles.profileTabs}>
        {PROFILES.map((p) => {
          const active = p === activeProfile;
          const count = flows.filter((f) => f.profile === p).length;
          return (
            <Pressable
              key={p}
              onPress={() => setActiveProfile(p)}
              style={({ pressed }) => [
                styles.profileTab,
                active && styles.profileTabActive,
                pressed && styles.tabPressed,
              ]}>
              <Text style={[
                styles.profileTabText,
                active && styles.profileTabTextActive,
              ]}>
                {PROFILE_LABEL[p]}
              </Text>
              {count > 0 ? (
                <View style={[
                  styles.profileTabCount,
                  active && styles.profileTabCountActive,
                ]}>
                  <Text style={[
                    styles.profileTabCountText,
                    active && styles.profileTabCountTextActive,
                  ]}>{count}</Text>
                </View>
              ) : null}
            </Pressable>
          );
        })}
      </View>

      <View style={[styles.list, styles.flowsListAfterLink]}>
        {flows.filter((f) => f.profile === activeProfile).length === 0 ? (
          <Pressable
            onPress={() => setNewFlowPickerOpen(true)}
            accessibilityLabel="Create your first Flow"
            style={({ pressed }) => [
              styles.onboardingCard,
              pressed && styles.onboardingCardPressed,
            ]}>
            {/* ᛉ algiz — the rune for protection / warding. */}
            <Text style={styles.onboardingRune}>ᛉ</Text>
            <Text style={styles.onboardingTitle}>No flows yet</Text>
            <Text style={styles.onboardingBody}>Create a flow to get started</Text>
          </Pressable>
        ) : (
          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 (
              <View key={f.id} style={styles.rowOuter}>
                <Pressable
                  onPress={() => setEditingFlow(f)}
                  onLongPress={() => setEditingFlow(f)}
                  delayLongPress={400}
                  style={({ pressed }) => [
                    styles.rowLeft,
                    pressed && styles.rowPressed,
                  ]}>
                  {f.profile !== 'privacy' ? (
                    <View style={[
                      styles.profileBadge,
                      f.profile === 'raw' && styles.profileBadgeRaw,
                      f.profile === 'work' && styles.profileBadgeWork,
                    ]} />
                  ) : null}
                  {b && ASSET_ICONS[b.id] ? (
                    <Image source={ASSET_ICONS[b.id]} style={styles.icon} />
                  ) : b && icons[b.pkg] ? (
                    <Image source={{ uri: icons[b.pkg] }} style={styles.icon} />
                  ) : (
                    <View
                      style={[
                        styles.swatch,
                        { backgroundColor: b?.tint ?? Palette.bgElevated },
                      ]}
                    />
                  )}
                  <View style={styles.rowText}>
                    <View style={styles.rowNameRow}>
                      <Text style={styles.rowName} numberOfLines={1}>
                        {rowName}
                      </Text>
                      {tag ? (
                        <View style={styles.flowTagPill}>
                          <Text style={styles.flowTagPillText} numberOfLines={1}>
                            {tag}
                          </Text>
                        </View>
                      ) : null}
                      {f.autoFire ? (
                        <View style={styles.autolaunchBadge}>
                          <Ionicons name="flash" size={11} color="#f6c84c" />
                        </View>
                      ) : null}
                    </View>
                    <FlowModePills flow={f} />
                    {b ? <RatingsRow browser={b} /> : null}
                  </View>
                </Pressable>
                <Pressable
                  disabled={launchDisabled}
                  onPress={() => handleOpenFlow(f)}
                  onLongPress={() => setOpenOptionsFlow(f)}
                  delayLongPress={400}
                  style={({ pressed }) => [
                    styles.rowRight,
                    launchDisabled && styles.rowDisabled,
                    pressed && styles.rowPressed,
                  ]}>
                  <Ionicons
                    name={
                      isShareMode
                        ? 'share-outline'
                        : isMaxPrivacy(f) ? 'glasses' : 'globe-outline'
                    }
                    size={20}
                    color={Palette.accentBright}
                  />
                  <Text style={styles.rowRightLabel} numberOfLines={2}>
                    {isConfigPlaceholderMode || isLegacySentinel
                      ? (b?.configUrl ?? 'No flags')
                      : isFlagMode
                        ? 'Share\nlink'
                        : isMaxPrivacy(f) ? 'Open\nIncognito' : 'Open'}
                  </Text>
                </Pressable>
              </View>
            );
          })
        )}
      </View>
      </View>

      {/* 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. */}
      <View style={styles.runeDivider}>
        <View style={styles.runeDividerLine} />
        <Text style={styles.runeDividerGlyph}>ᛉ  ᛟ  ᛉ</Text>
        <View style={styles.runeDividerLine} />
      </View>

      {/* Bottom action row — + New Flow only. Share Final lives in
          the status row above; no duplicate here. */}
      <View style={styles.bottomActionRow}>
        <Pressable
          onPress={() => setNewFlowPickerOpen(true)}
          style={({ pressed }) => [
            styles.newProfileBtn,
            pressed && styles.tabPressed,
          ]}>
          <Ionicons name="add" size={14} color={Palette.accentBright} />
          <Text style={styles.newProfileBtnText}>New Flow</Text>
        </Pressable>
      </View>
      </View>

      {errorMsg ? (
        <View style={styles.errorBox}>
          <Text style={styles.errorText}>{errorMsg}</Text>
        </View>
      ) : null}

      <FlowEditorModal
        flow={editingFlow}
        installed={installed}
        icons={icons}
        onSave={(f) => {
          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)}
      />

      <FlowActionSheet
        // Re-derive from the live flows array so the sheet reflects any
        // in-place mutations without closing.
        flow={actionFlow ? flows.find((f) => 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)}
      />

      <OpenOptionsMenu
        flow={openOptionsFlow}
        onOpen={() => {
          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. */}
      <BrowserPicker
        visible={newFlowPickerOpen}
        installed={installed}
        icons={icons}
        selected=""
        onPick={(pkg) => {
          setNewFlowPickerOpen(false);
          setEditingFlow(startNewFlow(pkg));
        }}
        onClose={() => setNewFlowPickerOpen(false)}
      />

      <AboutModal visible={aboutOpen} onClose={() => setAboutOpen(false)} />

      <DonateModal
        visible={donateOpen}
        onClose={() => setDonateOpen(false)}
        onPickUrl={(url) => {
          setTarget(url);
          setErrorMsg(null);
          setDonateOpen(false);
        }}
      />

      <SettingsModal
        visible={settingsOpen}
        stripTracking={stripTracking}
        onSetStripTracking={updateStripTracking}
        runicNames={runicNames}
        onSetRunicNames={updateRunicNames}
        showPaste={showPaste}
        onSetShowPaste={updateShowPaste}
        showClear={showClear}
        onSetShowClear={updateShowClear}
        proxyConfig={proxyConfig}
        onSetProxyConfig={updateProxyConfig}
        onClose={() => 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 (
            <ProxyDestinationModal
              dest={builtin}
              proxyConfig={proxyConfig}
              onSetProxyConfig={updateProxyConfig}
              onClose={() => setQuickProxyDestId(null)}
            />
          );
        }
        const custom = proxyConfig.customDests.find((c) => c.id === quickProxyDestId);
        if (custom) {
          return (
            <CustomProxyDestinationModal
              dest={custom}
              proxyConfig={proxyConfig}
              onSetProxyConfig={updateProxyConfig}
              onClose={() => setQuickProxyDestId(null)}
            />
          );
        }
        return null;
      })()}
    </ScrollView>
    </RunicNamesContext.Provider>
  );
}

/**
 * "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 (
    <Modal visible={flow !== null} transparent animationType="fade" onRequestClose={onClose}>
      <Pressable style={modalStyles.backdrop} onPress={onClose}>
        <Pressable style={[modalStyles.sheet, openOptionsStyles.sheet]} onPress={() => {}}>
          <Text style={modalStyles.title}>{b?.name ?? flow?.browserPkg ?? 'Open'}</Text>
          <Text style={modalStyles.subtitle}>How do you want to open it?</Text>

          <Pressable
            onPress={onOpen}
            style={({ pressed }) => [
              openOptionsStyles.row,
              openOptionsStyles.primary,
              pressed && modalStyles.actionBtnPressed,
            ]}>
            <Ionicons
              name={flow && isMaxPrivacy(flow) ? 'glasses' : 'globe-outline'}
              size={20}
              color={Palette.text}
            />
            <View style={openOptionsStyles.rowText}>
              <Text style={openOptionsStyles.rowLabel}>{defaultLabel}</Text>
              <Text style={openOptionsStyles.rowHint}>
                Use this Flow's configured mode + extras
              </Text>
            </View>
            <Ionicons name="chevron-forward" size={14} color={Palette.text} />
          </Pressable>

          <Pressable
            onPress={onOpenNormal}
            style={({ pressed }) => [
              openOptionsStyles.row,
              openOptionsStyles.secondary,
              pressed && modalStyles.actionBtnPressed,
            ]}>
            <Ionicons name="open-outline" size={20} color={Palette.accentBright} />
            <View style={openOptionsStyles.rowText}>
              <Text style={[openOptionsStyles.rowLabel, openOptionsStyles.rowLabelLight]}>
                Open Normal
              </Text>
              <Text style={[openOptionsStyles.rowHint, openOptionsStyles.rowHintLight]}>
                Plain tab — no incognito, no extras
              </Text>
            </View>
            <Ionicons name="chevron-forward" size={14} color={Palette.textMuted} />
          </Pressable>

          <View style={modalStyles.explainCloseRow}>
            <View style={modalStyles.cycleBtnSpacer} />
            <Pressable
              onPress={onClose}
              style={({ pressed }) => [
                editorStyles.cancelBtn,
                pressed && modalStyles.actionBtnPressed,
              ]}>
              <Text style={modalStyles.closeBtnText}>Cancel</Text>
            </Pressable>
            <View style={modalStyles.cycleBtnSpacer} />
          </View>
        </Pressable>
      </Pressable>
    </Modal>
  );
}

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<PresetGroup | null>(null);
  const [customInput, setCustomInput] = useState('');
  const [customError, setCustomError] = useState<string | null>(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<string | null>(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 (
    <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
      <Pressable style={modalStyles.backdrop} onPress={onClose}>
        <KeyboardAvoidingView
          behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
          style={modalStyles.keyboardWrap}
          pointerEvents="box-none">
        <Pressable style={modalStyles.sheet} onPress={() => {}}>
          {/* Elegant header — icon + title + close, matching the
              encyclopedia's chrome. */}
          <View style={modalStyles.headerSticky}>
            <View style={[modalStyles.headerIcon, modalStyles.glossaryHeaderIcon]}>
              <Ionicons name="bookmarks-outline" size={20} color={Palette.highlight} />
            </View>
            <View style={modalStyles.headerText}>
              <Text style={modalStyles.encTitle}>Presets</Text>
              <Text style={modalStyles.encSubtitle}>Saved & diagnostic links</Text>
            </View>
            <Pressable
              onPress={onClose}
              hitSlop={10}
              accessibilityLabel="Close"
              style={({ pressed }) => [
                modalStyles.headerCloseBtn,
                pressed && modalStyles.actionBtnPressed,
              ]}>
              <Ionicons name="close" size={20} color={Palette.textMuted} />
            </Pressable>
          </View>

          {/* Tab strip — Custom always leads (segmented pills, same
              vocabulary as the encyclopedia tabs). */}
          <View style={modalStyles.encTabStrip}>
            {(['custom', 'checks'] as const).map((id) => (
              <Pressable
                key={id}
                onPress={() => setActiveTab(id)}
                style={({ pressed }) => [
                  modalStyles.encTab,
                  activeTab === id && modalStyles.encTabActive,
                  pressed && modalStyles.actionBtnPressed,
                ]}>
                <Ionicons
                  name={id === 'custom' ? 'star-outline' : 'flask-outline'}
                  size={14}
                  color={activeTab === id ? Palette.text : Palette.textMuted}
                />
                <Text
                  style={[
                    modalStyles.encTabText,
                    activeTab === id && modalStyles.encTabTextActive,
                  ]}>
                  {id === 'custom' ? 'Custom' : 'Checks & Config'}
                </Text>
              </Pressable>
            ))}
          </View>

          <ScrollView
            style={[styles.presetsScroll, modalStyles.presetsScrollPanel]}
            contentContainerStyle={styles.presetsScrollContent}
            showsVerticalScrollIndicator>
            {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) => (
              <Pressable
                key={group.id}
                onPress={() => setOpenGroup(group)}
                style={({ pressed }) => [
                  styles.testsRow,
                  styles.testsRowGroup,
                  pressed && styles.testsRowPressed,
                ]}>
                <Ionicons
                  name="layers-outline"
                  size={16}
                  color={Palette.accentBright}
                  style={styles.presetIcon}
                />
                <View style={styles.testsRowText}>
                  <Text style={styles.testsRowLabel}>{group.label}</Text>
                  <Text style={styles.testsRowHint} numberOfLines={1}>
                    {group.items.length} link{group.items.length === 1 ? '' : 's'}
                  </Text>
                </View>
                <Ionicons name="chevron-forward" size={14} color={Palette.accentBright} />
              </Pressable>
            ))}
            </>
            ) : (
            /* Custom tab — user-added URLs render flat (no submenu
               drill-down). Same bordered panel as before so reopening
               the modal feels continuous. */
            <View style={styles.presetCustomPanel}>
              {/* 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 ? (
                <View style={styles.presetCustomHeaderRow}>
                  <Pressable
                    onPress={() => setAddingCustom(true)}
                    hitSlop={6}
                    style={({ pressed }) => [
                      styles.presetCustomAddChip,
                      pressed && modalStyles.actionBtnPressed,
                    ]}>
                    <Ionicons name="add" size={12} color={Palette.highlight} />
                    <Text style={styles.presetCustomAddChipText}>Add URL</Text>
                  </Pressable>
                </View>
              ) : 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.
                  <Pressable
                    onPress={() => setAddingCustom(true)}
                    hitSlop={6}
                    style={({ pressed }) => [
                      styles.presetCustomEmptyCta,
                      pressed && modalStyles.actionBtnPressed,
                    ]}>
                    <Ionicons name="add" size={14} color={Palette.highlight} />
                    <Text style={styles.presetCustomEmptyCtaText}>Add URL</Text>
                  </Pressable>
                ) : null
              ) : (
                <>
                  {customPresets.map((url) => (
                    <Pressable
                      key={url}
                      onPress={() => onPickUrl(url)}
                      onLongPress={() => setActionUrl(url)}
                      delayLongPress={400}
                      style={({ pressed }) => [styles.testsRow, pressed && styles.testsRowPressed]}>
                      <Ionicons
                        name="link-outline"
                        size={16}
                        color={Palette.accentBright}
                        style={styles.presetIcon}
                      />
                      <View style={styles.testsRowText}>
                        <Text style={styles.testsRowHint} numberOfLines={1}>
                          {url}
                        </Text>
                      </View>
                    </Pressable>
                  ))}
                  {/* 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. */}
                  <Text style={styles.presetCustomFootHint}>
                    Long-press a link for options
                  </Text>
                </>
              )}

              {addingCustom ? (
                <>
                  <View style={styles.presetCustomAddRow}>
                    <TextInput
                      value={customInput}
                      onChangeText={(t) => { 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
                    />
                    <Pressable
                      onPress={cancelAddCustom}
                      hitSlop={6}
                      style={({ pressed }) => [
                        styles.presetCustomAddBtn,
                        { borderColor: Palette.border },
                        pressed && modalStyles.actionBtnPressed,
                      ]}>
                      <Ionicons name="close" size={14} color={Palette.textMuted} />
                    </Pressable>
                    <Pressable
                      onPress={addCustomPreset}
                      disabled={customInput.trim().length === 0}
                      style={({ pressed }) => [
                        styles.presetCustomAddBtn,
                        customInput.trim().length === 0 && { opacity: 0.4 },
                        pressed && modalStyles.actionBtnPressed,
                      ]}>
                      <Ionicons name="checkmark" size={16} color={Palette.highlight} />
                    </Pressable>
                  </View>
                  {customError ? (
                    <Text style={styles.presetCustomError}>{customError}</Text>
                  ) : null}
                </>
              ) : null}
            </View>
            )}
          </ScrollView>
        </Pressable>
        </KeyboardAvoidingView>
      </Pressable>

      <PresetGroupModal
        group={openGroup}
        onClose={() => setOpenGroup(null)}
        onPickUrl={(url) => {
          setOpenGroup(null);
          onPickUrl(url);
        }}
      />

      <CustomPresetActionSheet
        url={actionUrl}
        canMoveUp={actionUrl ? customPresets.indexOf(actionUrl) > 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)}
      />
    </Modal>
  );
}

/**
 * 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<string | null>(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 <Modal visible={false} transparent onRequestClose={onClose}><View /></Modal>;
  }

  const commitEdit = () => {
    const err = onEdit(draft);
    if (err) setEditError(err);
  };

  return (
    <Modal visible transparent animationType="fade" onRequestClose={onClose}>
      <Pressable style={modalStyles.backdrop} onPress={onClose}>
        <Pressable style={modalStyles.sheet} onPress={() => {}}>
          <View style={modalStyles.header}>
            <Ionicons
              name="link-outline"
              size={18}
              color={Palette.highlight}
            />
            <View style={modalStyles.headerText}>
              <Text style={modalStyles.title} numberOfLines={1}>
                Custom URL
              </Text>
              <Text style={modalStyles.subtitle} numberOfLines={1}>
                {url}
              </Text>
            </View>
          </View>

          {editing ? (
            <View style={modalStyles.actionList}>
              <View style={styles.presetCustomAddRow}>
                <TextInput
                  value={draft}
                  onChangeText={(t) => { 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
                />
                <Pressable
                  onPress={() => { setEditing(false); setEditError(null); setDraft(url); }}
                  hitSlop={6}
                  style={({ pressed }) => [
                    styles.presetCustomAddBtn,
                    { borderColor: Palette.border },
                    pressed && modalStyles.actionBtnPressed,
                  ]}>
                  <Ionicons name="close" size={14} color={Palette.textMuted} />
                </Pressable>
                <Pressable
                  onPress={commitEdit}
                  disabled={draft.trim().length === 0}
                  style={({ pressed }) => [
                    styles.presetCustomAddBtn,
                    draft.trim().length === 0 && { opacity: 0.4 },
                    pressed && modalStyles.actionBtnPressed,
                  ]}>
                  <Ionicons name="checkmark" size={16} color={Palette.highlight} />
                </Pressable>
              </View>
              {editError ? (
                <Text style={styles.presetCustomError}>{editError}</Text>
              ) : null}
            </View>
          ) : (
            <View style={modalStyles.actionList}>
              <Pressable
                onPress={() => setEditing(true)}
                style={({ pressed }) => [
                  modalStyles.actionBtnPrimary,
                  pressed && modalStyles.actionBtnPressed,
                ]}>
                <Ionicons name="create-outline" size={18} color={Palette.highlight} />
                <Text style={modalStyles.actionBtnPrimaryText}>Edit</Text>
              </Pressable>

              {canMoveUp || canMoveDown ? (
                <View style={modalStyles.moveRow}>
                  <Pressable
                    onPress={() => canMoveUp && onMove(-1)}
                    disabled={!canMoveUp}
                    style={({ pressed }) => [
                      modalStyles.actionBtn,
                      modalStyles.moveBtn,
                      !canMoveUp && modalStyles.moveBtnDisabled,
                      pressed && modalStyles.actionBtnPressed,
                    ]}>
                    <Ionicons
                      name="arrow-up"
                      size={18}
                      color={canMoveUp ? Palette.accentBright : Palette.textMuted}
                    />
                    <Text style={modalStyles.actionBtnText}>Move up</Text>
                  </Pressable>
                  <Pressable
                    onPress={() => canMoveDown && onMove(1)}
                    disabled={!canMoveDown}
                    style={({ pressed }) => [
                      modalStyles.actionBtn,
                      modalStyles.moveBtn,
                      !canMoveDown && modalStyles.moveBtnDisabled,
                      pressed && modalStyles.actionBtnPressed,
                    ]}>
                    <Ionicons
                      name="arrow-down"
                      size={18}
                      color={canMoveDown ? Palette.accentBright : Palette.textMuted}
                    />
                    <Text style={modalStyles.actionBtnText}>Move down</Text>
                  </Pressable>
                </View>
              ) : null}

              <Pressable
                onPress={onDelete}
                style={({ pressed }) => [
                  modalStyles.actionBtn,
                  modalStyles.actionBtnDestructive,
                  pressed && modalStyles.actionBtnPressed,
                ]}>
                <Ionicons name="trash-outline" size={18} color="#c87070" />
                <Text style={[modalStyles.actionBtnText, { color: '#c87070' }]}>
                  Delete
                </Text>
              </Pressable>
            </View>
          )}

          <BackPill onPress={onClose} label="Close" />
        </Pressable>
      </Pressable>
    </Modal>
  );
}

/**
 * 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 <Modal visible={false} transparent onRequestClose={onClose}><View /></Modal>;
  }
  return (
    <Modal visible transparent animationType="fade" onRequestClose={onClose}>
      <Pressable style={modalStyles.backdrop} onPress={onClose}>
        <Pressable style={modalStyles.sheet} onPress={() => {}}>
          <Text style={modalStyles.title}>{group.label}</Text>
          <Text style={modalStyles.subtitle}>
            Drop a known URL into the link field
          </Text>

          <ScrollView
            style={styles.presetsScroll}
            contentContainerStyle={styles.presetsScrollContent}
            showsVerticalScrollIndicator>
            {group.items.map((t) => (
              <Pressable
                key={t.url}
                onPress={() => onPickUrl(t.url)}
                style={({ pressed }) => [styles.testsRow, pressed && styles.testsRowPressed]}>
                <Ionicons
                  name={t.icon}
                  size={16}
                  color={Palette.accentBright}
                  style={styles.presetIcon}
                />
                <View style={styles.testsRowText}>
                  <Text style={styles.testsRowLabel}>{t.label}</Text>
                  <Text style={styles.testsRowHint} numberOfLines={1}>
                    {t.url}
                  </Text>
                </View>
              </Pressable>
            ))}
          </ScrollView>

          <BackPill onPress={onClose} />
        </Pressable>
      </Pressable>
    </Modal>
  );
}

/**
 * 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 (
    <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
      <Pressable style={modalStyles.backdrop} onPress={onClose}>
        <Pressable style={[modalStyles.sheetTall, settingsStyles.listSheet]} onPress={() => {}}>
          <View style={settingsStyles.header}>
            <Ionicons name="trash-bin-outline" size={18} color={Palette.highlight} />
            <Text style={modalStyles.title}>Stripped parameters</Text>
          </View>
          <Text style={settingsStyles.listSubtitle}>
            Removed from every launched URL.
          </Text>

          <ScrollView
            style={[settingsStyles.listScroll, { maxHeight: scrollMaxH }]}
            contentContainerStyle={settingsStyles.listScrollContent}
            persistentScrollbar
            showsVerticalScrollIndicator
            indicatorStyle="white"
            nestedScrollEnabled>
            <Text style={settingsStyles.listSectionTitle}>Prefix matches</Text>
            <View style={settingsStyles.chipGrid}>
              {TRACKING_PREFIXES.map((p) => (
                <View key={p} style={settingsStyles.chip}>
                  <Text style={settingsStyles.chipText}>{p}*</Text>
                </View>
              ))}
            </View>

            <Text style={[settingsStyles.listSectionTitle, { marginTop: 14 }]}>
              Exact matches
            </Text>
            <View style={settingsStyles.chipGrid}>
              {params.map((k) => (
                <View key={k} style={settingsStyles.chip}>
                  <Text style={settingsStyles.chipText}>{k}</Text>
                </View>
              ))}
            </View>

            {/* 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). */}
            <Text style={[settingsStyles.listSectionTitle, { marginTop: 14 }]}>
              Per-site rules
            </Text>
            {(() => {
              // 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) => (
                <View key={g.hosts.join(',')} style={settingsStyles.siteBlock}>
                  <Text style={settingsStyles.siteHost}>{g.hosts.join(' / ')}</Text>
                  <View style={settingsStyles.chipGrid}>
                    {g.params.map((k) => (
                      <View key={k} style={settingsStyles.chip}>
                        <Text style={settingsStyles.chipText}>{k}</Text>
                      </View>
                    ))}
                  </View>
                </View>
              ));
            })()}
          </ScrollView>

          <BackPill onPress={onClose} />
        </Pressable>
      </Pressable>
    </Modal>
  );
}

/**
 * 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<ProxyDestination | null>(null);
  const [openCustom, setOpenCustom] = useState<CustomDest | null>(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 (
    <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
      <Pressable style={modalStyles.backdrop} onPress={onClose}>
        <Pressable style={[modalStyles.sheet, settingsStyles.sheet]} onPress={() => {}}>
          <View style={settingsStyles.header}>
            <Ionicons name="shuffle-outline" size={18} color={Palette.highlight} />
            <Text style={modalStyles.title}>Privacy proxies</Text>
          </View>
          <Text style={settingsStyles.listSubtitle}>
            Pick which sites get redirected, and tune hostname choice per site.
          </Text>

          {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 (
              <Pressable
                key={d.id}
                onPress={() => setOpenDest(d)}
                style={({ pressed }) => [
                  settingsStyles.row,
                  { marginTop: 8 },
                  pressed && modalStyles.actionBtnPressed,
                ]}>
                <View style={settingsStyles.rowText}>
                  <Text style={settingsStyles.rowLabel}>{d.label}</Text>
                  <Text style={settingsStyles.rowHint}>
                    {enabledCount} of {total} hostname{total === 1 ? '' : 's'} enabled
                    {enabledCount > 1 ? ' · randomised' : ''}
                  </Text>
                </View>
                <Ionicons name="chevron-forward" size={16} color={Palette.accentBright} />
              </Pressable>
            );
          })}

          {proxyConfig.customDests.map((c) => {
            const enabled = c.instances.filter((h) => !c.disabled.includes(h)).length;
            return (
              <Pressable
                key={c.id}
                onPress={() => setOpenCustom(c)}
                style={({ pressed }) => [
                  settingsStyles.row,
                  { marginTop: 8 },
                  pressed && modalStyles.actionBtnPressed,
                ]}>
                <View style={settingsStyles.rowText}>
                  <View style={settingsStyles.rowLabelRow}>
                    <Text style={settingsStyles.rowLabel}>{c.label}</Text>
                    <View style={settingsStyles.customBadge}>
                      <Text style={settingsStyles.customBadgeText}>CUSTOM</Text>
                    </View>
                  </View>
                  <Text style={settingsStyles.rowHint}>
                    {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' : ''}`}
                  </Text>
                </View>
                <Ionicons name="chevron-forward" size={16} color={Palette.accentBright} />
              </Pressable>
            );
          })}

          {/* Add custom proxy — inline title prompt expands when tapped. */}
          {!addingOpen ? (
            <Pressable
              onPress={() => setAddingOpen(true)}
              style={({ pressed }) => [
                settingsStyles.addCustomDestBtn,
                pressed && modalStyles.actionBtnPressed,
              ]}>
              <Ionicons name="add" size={14} color={Palette.highlight} />
              <Text style={settingsStyles.addCustomDestText}>Add custom proxy</Text>
            </Pressable>
          ) : (
            <View style={settingsStyles.customAddRow}>
              <TextInput
                value={addTitle}
                onChangeText={setAddTitle}
                onSubmitEditing={addCustomDest}
                placeholder="Title — e.g. Reddit via Redlib"
                placeholderTextColor={Palette.textMuted}
                autoCapitalize="sentences"
                autoCorrect={false}
                style={settingsStyles.customAddInput}
                autoFocus
              />
              <Pressable
                onPress={() => { setAddTitle(''); setAddingOpen(false); }}
                hitSlop={6}
                style={({ pressed }) => [
                  settingsStyles.customAddBtn,
                  { borderColor: Palette.border },
                  pressed && modalStyles.actionBtnPressed,
                ]}>
                <Ionicons name="close" size={14} color={Palette.textMuted} />
              </Pressable>
              <Pressable
                onPress={addCustomDest}
                disabled={addTitle.trim().length === 0}
                style={({ pressed }) => [
                  settingsStyles.customAddBtn,
                  addTitle.trim().length === 0 && { opacity: 0.4 },
                  pressed && modalStyles.actionBtnPressed,
                ]}>
                <Ionicons name="checkmark" size={16} color={Palette.highlight} />
              </Pressable>
            </View>
          )}

          <BackPill onPress={onClose} />
        </Pressable>
      </Pressable>

      <ProxyDestinationModal
        dest={openDest}
        proxyConfig={proxyConfig}
        onSetProxyConfig={onSetProxyConfig}
        onClose={() => setOpenDest(null)}
      />

      <CustomProxyDestinationModal
        dest={openCustom}
        proxyConfig={proxyConfig}
        onSetProxyConfig={onSetProxyConfig}
        onClose={() => setOpenCustom(null)}
      />
    </Modal>
  );
}

/**
 * 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<string | null>(null);
  const [instanceInput, setInstanceInput] = useState('');
  const [instanceError, setInstanceError] = useState<string | null>(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 <Modal visible={false} transparent onRequestClose={onClose}><View /></Modal>;
  }

  // All mutations: clone the customDests array with one entry patched
  // (or removed). Keeps the rest of the config untouched.
  const updateThis = (patch: Partial<CustomDest>) => {
    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 (
    <Modal visible transparent animationType="fade" onRequestClose={onClose}>
      <Pressable style={modalStyles.backdrop} onPress={onClose}>
        <KeyboardAvoidingView
          behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
          style={modalStyles.keyboardWrap}
          pointerEvents="box-none">
        <Pressable style={[modalStyles.sheetTall, settingsStyles.sheet]} onPress={() => {}}>
          <View style={settingsStyles.header}>
            <Ionicons name="construct-outline" size={18} color={Palette.highlight} />
            <Text style={modalStyles.title}>{dest.label || 'Custom proxy'}</Text>
          </View>

          <ScrollView
            style={[settingsStyles.customDestScroll, { maxHeight: scrollMaxH }]}
            contentContainerStyle={{ paddingVertical: 4 }}
            keyboardShouldPersistTaps="handled"
            nestedScrollEnabled>
            {/* Title editor — commit on blur or submit. */}
            <Text style={settingsStyles.listSectionTitle}>Title</Text>
            <View style={settingsStyles.fieldRow}>
              <TextInput
                value={titleDraft}
                onChangeText={setTitleDraft}
                onBlur={commitTitle}
                onSubmitEditing={commitTitle}
                placeholder="Title"
                placeholderTextColor={Palette.textMuted}
                autoCapitalize="sentences"
                autoCorrect={false}
                style={settingsStyles.fieldInput}
              />
            </View>

            {/* Match hosts — which sites this custom proxy intercepts. */}
            <Text style={[settingsStyles.listSectionTitle, { marginTop: 14 }]}>
              Match hosts
            </Text>
            {dest.matches.length === 0 ? (
              <Text style={settingsStyles.emptyNote}>
                No hosts yet — add one below.
              </Text>
            ) : (
              dest.matches.map((host) => (
                <View key={host} style={settingsStyles.instanceRow}>
                  <Text style={settingsStyles.instanceHost} numberOfLines={1}>
                    {host}
                  </Text>
                  <Pressable
                    onPress={() => removeMatch(host)}
                    hitSlop={6}
                    style={({ pressed }) => [
                      settingsStyles.instanceRemove,
                      pressed && modalStyles.actionBtnPressed,
                    ]}>
                    <Ionicons name="close" size={14} color={Palette.textMuted} />
                  </Pressable>
                </View>
              ))
            )}
            <View style={settingsStyles.customAddRow}>
              <TextInput
                value={matchInput}
                onChangeText={(t) => { 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}
              />
              <Pressable
                onPress={addMatch}
                disabled={matchInput.trim().length === 0}
                style={({ pressed }) => [
                  settingsStyles.customAddBtn,
                  matchInput.trim().length === 0 && { opacity: 0.4 },
                  pressed && modalStyles.actionBtnPressed,
                ]}>
                <Ionicons name="add" size={16} color={Palette.highlight} />
              </Pressable>
            </View>
            {matchError ? (
              <Text style={settingsStyles.customAddError}>{matchError}</Text>
            ) : null}

            {/* Instances — picked at random when more than one is
                enabled. Cycle button on the URL status row steps
                forward through the enabled rotation. */}
            <Text style={[settingsStyles.listSectionTitle, { marginTop: 14, marginBottom: 6 }]}>
              Hostnames
            </Text>
            <Text style={settingsStyles.emptyNote}>
              Picked at random when more than one is enabled.
            </Text>
            {dest.instances.length === 0 ? (
              <Text style={settingsStyles.emptyNote}>
                No hostnames yet — add one below.
              </Text>
            ) : (
              dest.instances.map((host) => {
                const isOn = !dest.disabled.includes(host);
                return (
                  <Pressable
                    key={host}
                    onPress={() => toggleInstance(host)}
                    style={({ pressed }) => [
                      settingsStyles.instanceRow,
                      pressed && modalStyles.actionBtnPressed,
                    ]}>
                    <Text style={settingsStyles.instanceHost} numberOfLines={1}>
                      {host}
                    </Text>
                    <Pressable
                      onPress={() => removeInstance(host)}
                      hitSlop={6}
                      style={({ pressed }) => [
                        settingsStyles.instanceRemove,
                        pressed && modalStyles.actionBtnPressed,
                      ]}>
                      <Ionicons name="close" size={14} color={Palette.textMuted} />
                    </Pressable>
                    <View
                      style={[
                        editorStyles.switchTrack,
                        isOn && editorStyles.switchTrackOn,
                      ]}>
                      <View
                        style={[
                          editorStyles.switchThumb,
                          isOn && editorStyles.switchThumbOn,
                        ]}
                      />
                    </View>
                  </Pressable>
                );
              })
            )}
            <View style={settingsStyles.customAddRow}>
              <TextInput
                value={instanceInput}
                onChangeText={(t) => { setInstanceInput(t); if (instanceError) setInstanceError(null); }}
                onSubmitEditing={addInstance}
                placeholder="Custom hostname"
                placeholderTextColor={Palette.textMuted}
                autoCapitalize="none"
                autoCorrect={false}
                keyboardType="url"
                style={settingsStyles.customAddInput}
              />
              <Pressable
                onPress={addInstance}
                disabled={instanceInput.trim().length === 0}
                style={({ pressed }) => [
                  settingsStyles.customAddBtn,
                  instanceInput.trim().length === 0 && { opacity: 0.4 },
                  pressed && modalStyles.actionBtnPressed,
                ]}>
                <Ionicons name="add" size={16} color={Palette.highlight} />
              </Pressable>
            </View>
            {instanceError ? (
              <Text style={settingsStyles.customAddError}>{instanceError}</Text>
            ) : null}

            {/* Delete this custom destination — destructive, separated. */}
            <Pressable
              onPress={removeThis}
              style={({ pressed }) => [
                settingsStyles.deleteDestBtn,
                pressed && modalStyles.actionBtnPressed,
              ]}>
              <Ionicons name="trash-outline" size={14} color="#c87070" />
              <Text style={settingsStyles.deleteDestText}>Delete this proxy</Text>
            </Pressable>
          </ScrollView>

          <BackPill onPress={onClose} />
        </Pressable>
        </KeyboardAvoidingView>
      </Pressable>
    </Modal>
  );
}

/**
 * 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<string | null>(null);
  if (!dest) {
    return <Modal visible={false} transparent onRequestClose={onClose}><View /></Modal>;
  }
  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 (
    <Modal visible transparent animationType="fade" onRequestClose={onClose}>
      <Pressable style={modalStyles.backdrop} onPress={onClose}>
        <KeyboardAvoidingView
          behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
          style={modalStyles.keyboardWrap}
          pointerEvents="box-none">
        <Pressable style={[modalStyles.sheet, settingsStyles.sheet]} onPress={() => {}}>
          <View style={settingsStyles.header}>
            <Ionicons name="shuffle-outline" size={18} color={Palette.highlight} />
            <Text style={modalStyles.title}>{dest.label}</Text>
          </View>

          <Text style={[settingsStyles.listSectionTitle, { marginBottom: 6 }]}>
            Hostnames
          </Text>
          <Text style={settingsStyles.emptyNote}>
            Picked at random when more than one is enabled. Use the cycle
            button on the URL status row to step forward through them.
          </Text>

          {allInstances.map((inst) => {
            const isOn = !disabled.has(inst.host);
            const isCustom = customHosts.has(inst.host);
            return (
              <Pressable
                key={inst.host}
                onPress={() => toggleInstance(inst.host)}
                style={({ pressed }) => [
                  settingsStyles.instanceRow,
                  pressed && modalStyles.actionBtnPressed,
                ]}>
                <Text style={settingsStyles.instanceHost} numberOfLines={1}>
                  {inst.host}
                </Text>
                {isCustom ? (
                  <Pressable
                    onPress={() => removeCustom(inst.host)}
                    hitSlop={6}
                    accessibilityLabel="Remove custom instance"
                    style={({ pressed }) => [
                      settingsStyles.instanceRemove,
                      pressed && modalStyles.actionBtnPressed,
                    ]}>
                    <Ionicons
                      name="close"
                      size={14}
                      color={Palette.textMuted}
                    />
                  </Pressable>
                ) : null}
                <View
                  style={[
                    editorStyles.switchTrack,
                    isOn && editorStyles.switchTrackOn,
                  ]}>
                  <View
                    style={[
                      editorStyles.switchThumb,
                      isOn && editorStyles.switchThumbOn,
                    ]}
                  />
                </View>
              </Pressable>
            );
          })}

          {/* 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. */}
          <View style={settingsStyles.customAddRow}>
            <TextInput
              value={addInput}
              onChangeText={(t) => { setAddInput(t); if (addError) setAddError(null); }}
              onSubmitEditing={addCustom}
              placeholder="Custom instance"
              placeholderTextColor={Palette.textMuted}
              autoCapitalize="none"
              autoCorrect={false}
              keyboardType="url"
              style={settingsStyles.customAddInput}
            />
            <Pressable
              onPress={addCustom}
              disabled={addInput.trim().length === 0}
              style={({ pressed }) => [
                settingsStyles.customAddBtn,
                addInput.trim().length === 0 && { opacity: 0.4 },
                pressed && modalStyles.actionBtnPressed,
              ]}>
              <Ionicons name="add" size={16} color={Palette.highlight} />
            </Pressable>
          </View>
          {addError ? (
            <Text style={settingsStyles.customAddError}>{addError}</Text>
          ) : null}

          <BackPill onPress={onClose} />
        </Pressable>
        </KeyboardAvoidingView>
      </Pressable>
    </Modal>
  );
}

/**
 * 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 (
    <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
      <Pressable style={modalStyles.backdrop} onPress={onClose}>
        <Pressable style={[modalStyles.sheet, settingsStyles.sheet]} onPress={() => {}}>
          <View style={settingsStyles.header}>
            <Ionicons name="settings-outline" size={18} color={Palette.highlight} />
            <Text style={modalStyles.title}>Settings</Text>
          </View>

          {/* 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. */}
          <View style={settingsStyles.tabStrip}>
            <Pressable
              onPress={() => setActiveTab('privacy')}
              style={({ pressed }) => [
                settingsStyles.tab,
                activeTab === 'privacy' && settingsStyles.tabActive,
                pressed && modalStyles.actionBtnPressed,
              ]}>
              <Text
                style={[
                  settingsStyles.tabLabel,
                  activeTab === 'privacy' && settingsStyles.tabLabelActive,
                ]}>
                Privacy
              </Text>
            </Pressable>
            <Pressable
              onPress={() => setActiveTab('ui')}
              style={({ pressed }) => [
                settingsStyles.tab,
                activeTab === 'ui' && settingsStyles.tabActive,
                pressed && modalStyles.actionBtnPressed,
              ]}>
              <Text
                style={[
                  settingsStyles.tabLabel,
                  activeTab === 'ui' && settingsStyles.tabLabelActive,
                ]}>
                UI
              </Text>
            </Pressable>
          </View>

          {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. */}
          <View style={settingsStyles.row}>
            <Pressable
              onPress={() => setTrackingListOpen(true)}
              style={({ pressed }) => [
                settingsStyles.rowBodyZone,
                settingsStyles.rowBodyZoneRow,
                pressed && modalStyles.actionBtnPressed,
              ]}>
              <View style={{ flex: 1, gap: 3 }}>
                <Text style={settingsStyles.rowLabel}>Remove Tracking Params</Text>
                <Text style={settingsStyles.rowHint}>
                  Strip tracking parameters from launched URLs
                </Text>
              </View>
              <Ionicons
                name="chevron-forward"
                size={16}
                color={Palette.accentBright}
              />
            </Pressable>
            <Pressable
              onPress={() => onSetStripTracking(!stripTracking)}
              hitSlop={6}
              style={({ pressed }) => [
                settingsStyles.rowSwitchZone,
                pressed && modalStyles.actionBtnPressed,
              ]}>
              <View style={settingsStyles.rowSep} />
              <View
                style={[
                  editorStyles.switchTrack,
                  stripTracking && editorStyles.switchTrackOn,
                ]}>
                <View
                  style={[
                    editorStyles.switchThumb,
                    stripTracking && editorStyles.switchThumbOn,
                  ]}
                />
              </View>
            </Pressable>
          </View>

          <TrackingListModal
            visible={trackingListOpen}
            onClose={() => setTrackingListOpen(false)}
          />


          {/* Use proxies — split row: body opens the destinations
              list, switch zone toggles the master. Uniform with the
              Tracking row above. */}
          <View style={[settingsStyles.row, { marginTop: 10 }]}>
            <Pressable
              onPress={() => setProxiesOpen(true)}
              style={({ pressed }) => [
                settingsStyles.rowBodyZone,
                settingsStyles.rowBodyZoneRow,
                pressed && modalStyles.actionBtnPressed,
              ]}>
              <View style={{ flex: 1, gap: 3 }}>
                <Text style={settingsStyles.rowLabel}>Use proxies</Text>
                <Text style={settingsStyles.rowHint}>
                  Redirect sites to privacy-respecting proxies
                </Text>
              </View>
              <Ionicons
                name="chevron-forward"
                size={16}
                color={Palette.accentBright}
              />
            </Pressable>
            <Pressable
              onPress={() =>
                onSetProxyConfig({ ...proxyConfig, enabled: !proxyConfig.enabled })
              }
              hitSlop={6}
              style={({ pressed }) => [
                settingsStyles.rowSwitchZone,
                pressed && modalStyles.actionBtnPressed,
              ]}>
              <View style={settingsStyles.rowSep} />
              <View
                style={[
                  editorStyles.switchTrack,
                  proxyConfig.enabled && editorStyles.switchTrackOn,
                ]}>
                <View
                  style={[
                    editorStyles.switchThumb,
                    proxyConfig.enabled && editorStyles.switchThumbOn,
                  ]}
                />
              </View>
            </Pressable>
          </View>

          <ProxiesListModal
            visible={proxiesOpen}
            proxyConfig={proxyConfig}
            onSetProxyConfig={onSetProxyConfig}
            onClose={() => setProxiesOpen(false)}
          />
          </> : null}

          {activeTab === 'ui' ? <>
          {/* Runic browser names. No detail screen → body taps fall
              through to the same toggle as the switch zone. */}
          <View style={settingsStyles.row}>
            <Pressable
              onPress={() => onSetRunicNames(!runicNames)}
              style={({ pressed }) => [
                settingsStyles.rowBodyZone,
                pressed && modalStyles.actionBtnPressed,
              ]}>
              <Text style={settingsStyles.rowLabel}>Runic browser names</Text>
              <Text style={settingsStyles.rowHint}>
                Render browser names in Elder Futhark runes
              </Text>
            </Pressable>
            <Pressable
              onPress={() => onSetRunicNames(!runicNames)}
              hitSlop={6}
              style={({ pressed }) => [
                settingsStyles.rowSwitchZone,
                pressed && modalStyles.actionBtnPressed,
              ]}>
              <View style={settingsStyles.rowSep} />
              <View
                style={[
                  editorStyles.switchTrack,
                  runicNames && editorStyles.switchTrackOn,
                ]}>
                <View
                  style={[
                    editorStyles.switchThumb,
                    runicNames && editorStyles.switchThumbOn,
                  ]}
                />
              </View>
            </Pressable>
          </View>

          {/* Show Paste. No detail screen → body toggles. */}
          <View style={[settingsStyles.row, { marginTop: 10 }]}>
            <Pressable
              onPress={() => onSetShowPaste(!showPaste)}
              style={({ pressed }) => [
                settingsStyles.rowBodyZone,
                pressed && modalStyles.actionBtnPressed,
              ]}>
              <Text style={settingsStyles.rowLabel}>Show Paste</Text>
              <Text style={settingsStyles.rowHint}>
                Show the Paste pill in the SOURCE tools row
              </Text>
            </Pressable>
            <Pressable
              onPress={() => onSetShowPaste(!showPaste)}
              hitSlop={6}
              style={({ pressed }) => [
                settingsStyles.rowSwitchZone,
                pressed && modalStyles.actionBtnPressed,
              ]}>
              <View style={settingsStyles.rowSep} />
              <View
                style={[
                  editorStyles.switchTrack,
                  showPaste && editorStyles.switchTrackOn,
                ]}>
                <View
                  style={[
                    editorStyles.switchThumb,
                    showPaste && editorStyles.switchThumbOn,
                  ]}
                />
              </View>
            </Pressable>
          </View>

          {/* Show Clear. No detail screen → body toggles. */}
          <View style={[settingsStyles.row, { marginTop: 10 }]}>
            <Pressable
              onPress={() => onSetShowClear(!showClear)}
              style={({ pressed }) => [
                settingsStyles.rowBodyZone,
                pressed && modalStyles.actionBtnPressed,
              ]}>
              <Text style={settingsStyles.rowLabel}>Show Clear</Text>
              <Text style={settingsStyles.rowHint}>
                Show the Clear pill in the SOURCE tools row
              </Text>
            </Pressable>
            <Pressable
              onPress={() => onSetShowClear(!showClear)}
              hitSlop={6}
              style={({ pressed }) => [
                settingsStyles.rowSwitchZone,
                pressed && modalStyles.actionBtnPressed,
              ]}>
              <View style={settingsStyles.rowSep} />
              <View
                style={[
                  editorStyles.switchTrack,
                  showClear && editorStyles.switchTrackOn,
                ]}>
                <View
                  style={[
                    editorStyles.switchThumb,
                    showClear && editorStyles.switchThumbOn,
                  ]}
                />
              </View>
            </Pressable>
          </View>
          </> : null}

          <View style={modalStyles.explainCloseRow}>
            <View style={modalStyles.cycleBtnSpacer} />
            <Pressable
              onPress={onClose}
              style={({ pressed }) => [
                editorStyles.cancelBtn,
                pressed && modalStyles.actionBtnPressed,
              ]}>
              <Text style={modalStyles.closeBtnText}>Close</Text>
            </Pressable>
            <View style={modalStyles.cycleBtnSpacer} />
          </View>
        </Pressable>
      </Pressable>
    </Modal>
  );
}

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 (
    <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
      <Pressable style={modalStyles.backdrop} onPress={onClose}>
        <Pressable style={modalStyles.sheet} onPress={() => {}}>
          {/* Elegant header — centered rune-flanked title, no
              utilitarian close X. A handshake centered between two
              hairline rails — clean and universally readable. */}
          <View style={donateStyles.headerOrnament}>
            <View style={donateStyles.headerRail} />
            <MaterialCommunityIcons
              name="handshake-outline"
              size={22}
              color={Palette.highlight}
              style={donateStyles.headerIcon}
            />
            <View style={donateStyles.headerRail} />
          </View>
          <Text style={donateStyles.headerTitle}>Donate</Text>

          <Text style={donateStyles.tagline}>
            If Warden stands watch well for you, consider a tribute.
            It will always be open source, free, and guarding your privacy — forever.
          </Text>

          <Text style={donateStyles.body}>
            Any cryptocurrency, through the link below.
          </Text>

          {/* 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. */}
          <View style={donateStyles.urlPanel}>
            <Ionicons name="link-outline" size={12} color={Palette.textMuted} />
            <Text style={donateStyles.urlText} numberOfLines={1}>
              {DONATION_URL}
            </Text>
          </View>

          <Pressable
            onPress={() => onPickUrl(DONATION_URL)}
            style={({ pressed }) => [
              donateStyles.primaryBtn,
              pressed && modalStyles.actionBtnPressed,
            ]}
            accessibilityLabel="Drop the donation URL into the link field">
            <Ionicons
              name="arrow-down-circle-outline"
              size={14}
              color={Palette.highlight}
            />
            <Text style={donateStyles.primaryBtnText}>Set as URL</Text>
          </Pressable>

          <View style={[modalStyles.explainCloseRow, { marginTop: 18 }]}>
            <View style={modalStyles.cycleBtnSpacer} />
            <Pressable
              onPress={onClose}
              style={({ pressed }) => [
                editorStyles.cancelBtn,
                pressed && modalStyles.actionBtnPressed,
              ]}>
              <Text style={modalStyles.closeBtnText}>Close</Text>
            </Pressable>
            <View style={modalStyles.cycleBtnSpacer} />
          </View>
        </Pressable>
      </Pressable>
    </Modal>
  );
}

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 (
    <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
      <Pressable style={modalStyles.backdrop} onPress={onClose}>
        <Pressable style={modalStyles.sheet} onPress={() => {}}>
          {/* 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. */}
          <Image
            source={require('./assets/images/warden-mark.png')}
            style={aboutStyles.logo}
            accessibilityIgnoresInvertColors
          />
          <Text style={[modalStyles.title, aboutStyles.titleCentered]}>Warden</Text>
          <Text style={aboutStyles.byline}>By Vikingware</Text>
          <Text style={aboutStyles.tagline}>
            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.
          </Text>

          <View style={aboutStyles.grid}>
            {pillars.map((p) => (
              <View key={p.title} style={aboutStyles.pillarRow}>
                <Ionicons name={p.icon} size={14} color={Palette.accentBright} style={aboutStyles.pillarIcon} />
                <View style={aboutStyles.pillarBody}>
                  <Text style={aboutStyles.pillarTitle}>{p.title}</Text>
                  <Text style={aboutStyles.pillarText}>{p.body}</Text>
                </View>
              </View>
            ))}
          </View>

          <Text style={aboutStyles.freeLine}>Open Source. Free. Forever.</Text>
          <Text style={aboutStyles.footnote}>
            No telemetry · MIT licensed
          </Text>
          {/* 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. */}
          <Text style={aboutStyles.buildLine}>
            v{BUILD_VERSION} · {BUILD_COMMIT}
          </Text>

          <View style={modalStyles.explainCloseRow}>
            <View style={modalStyles.cycleBtnSpacer} />
            <Pressable
              onPress={onClose}
              style={({ pressed }) => [
                editorStyles.cancelBtn,
                pressed && modalStyles.actionBtnPressed,
              ]}>
              <Text style={modalStyles.closeBtnText}>Close</Text>
            </Pressable>
            <View style={modalStyles.cycleBtnSpacer} />
          </View>
        </Pressable>
      </Pressable>
    </Modal>
  );
}

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<string, string> = {
  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 (
    <View style={modalStyles.scoreBarRow}>
      <View style={modalStyles.scoreBarHeader}>
        <Text style={modalStyles.scoreBarLabel}>{label}</Text>
        <Text style={modalStyles.scoreBarValue}>{score}</Text>
      </View>
      <View style={modalStyles.scoreBarTrack}>
        <View
          style={[
            modalStyles.scoreBarFill,
            { width: `${Math.max(0, Math.min(100, score))}%`, backgroundColor: colour },
          ]}
        />
      </View>
      {note ? <Text style={modalStyles.scoreBarNote}>{note}</Text> : null}
    </View>
  );
}

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 (
    <View style={modalStyles.scoreBreakdown}>
      {showS ? (
        <View style={modalStyles.scoreCol}>
          <View style={modalStyles.scoreColHeader}>
            <Ionicons name="shield-half-outline" size={14} color={Palette.accentBright} />
            <Text style={modalStyles.scoreColLabel}>Security</Text>
            <Text style={modalStyles.scoreColTotal}>
              {s.total}
              <Text style={modalStyles.scoreColScale}>/100</Text>
            </Text>
          </View>
          {s.parts.map((part, i) => (
            <ScoreBar key={i} label={part.label} score={part.score} note={part.note} />
          ))}
        </View>
      ) : null}
      {showS && showP ? <View style={modalStyles.scoreColDivider} /> : null}
      {showP ? (
        <View style={modalStyles.scoreCol}>
          <View style={modalStyles.scoreColHeader}>
            <Ionicons name="glasses-outline" size={14} color={Palette.accentBright} />
            <Text style={modalStyles.scoreColLabel}>Privacy</Text>
            <Text style={modalStyles.scoreColTotal}>
              {p.total}
              <Text style={modalStyles.scoreColScale}>/100</Text>
            </Text>
          </View>
          {p.parts.map((part, i) => (
            <ScoreBar key={i} label={part.label} score={part.score} note={part.note} />
          ))}
        </View>
      ) : null}
    </View>
  );
}

/**
 * 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<string>([
    ...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 (
      <View style={styles.modePillRow}>
        <Text style={styles.modeInlineTextMuted}>Normal</Text>
      </View>
    );
  }
  // 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 (
    <View style={styles.modePillRow}>
      {activeSegments.length > 0 ? (
        <Text style={styles.modeInlineText}>
          {activeSegments.map((label, i) => (
            <React.Fragment key={label}>
              {i > 0 ? <Text style={styles.modeInlineSep}> • </Text> : null}
              <Text>{label}</Text>
            </React.Fragment>
          ))}
        </Text>
      ) : null}
      {userExtras.length > 0 ? (
        <View style={styles.modePillMuted}>
          <Text style={styles.modePillTextMuted}>
            +{userExtras.length} extra{userExtras.length === 1 ? '' : 's'}
          </Text>
        </View>
      ) : null}
    </View>
  );
}

function RatingsRow({ browser }: { browser: Browser }) {
  const p = privacyScore(browser);
  const s = securityScore(browser);
  return (
    <View style={styles.ratingsRow}>
      <View style={styles.ratingPill}>
        <Ionicons name="shield-half-outline" size={11} color={Palette.accentBright} />
        <Text style={styles.ratingScore}>{s}</Text>
        <Text style={styles.ratingScale}>/100</Text>
      </View>
      <View style={styles.ratingPill}>
        <Ionicons name="glasses-outline" size={11} color={Palette.accentBright} />
        <Text style={styles.ratingScore}>{p}</Text>
        <Text style={styles.ratingScale}>/100</Text>
      </View>
    </View>
  );
}

// ────────────────────────────────────────────────────────────────────────
// 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 <Modal visible={false} transparent onRequestClose={onClose}><View /></Modal>;
  }
  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 (
    <Modal visible transparent animationType="fade" onRequestClose={onClose}>
      <Pressable style={modalStyles.backdrop} onPress={onClose}>
        <Pressable style={modalStyles.sheet} onPress={() => {}}>
          <View style={modalStyles.header}>
            {b && ASSET_ICONS[b.id] ? (
              <Image source={ASSET_ICONS[b.id]} style={modalStyles.headerIcon} />
            ) : (
              <View
                style={[
                  modalStyles.headerIcon,
                  { backgroundColor: b?.tint ?? Palette.bgElevated },
                ]}
              />
            )}
            <View style={modalStyles.headerText}>
              <Text style={modalStyles.title}>{title}</Text>
              {tag ? (
                <Text style={modalStyles.subtitle}>{tag}</Text>
              ) : null}
            </View>
          </View>

          <View style={modalStyles.actionList}>
            <Pressable
              onPress={onEdit}
              style={({ pressed }) => [
                modalStyles.actionBtnPrimary,
                pressed && modalStyles.actionBtnPressed,
              ]}>
              <Ionicons name="create-outline" size={18} color={Palette.highlight} />
              <Text style={modalStyles.actionBtnPrimaryText}>Edit</Text>
            </Pressable>

            {canMoveUp || canMoveDown ? (
              <View style={modalStyles.moveRow}>
                <Pressable
                  onPress={() => canMoveUp && onMove(-1)}
                  disabled={!canMoveUp}
                  style={({ pressed }) => [
                    modalStyles.actionBtn,
                    modalStyles.moveBtn,
                    !canMoveUp && modalStyles.moveBtnDisabled,
                    pressed && modalStyles.actionBtnPressed,
                  ]}>
                  <Ionicons
                    name="arrow-up"
                    size={18}
                    color={canMoveUp ? Palette.accentBright : Palette.textMuted}
                  />
                  <Text style={modalStyles.actionBtnText}>Move up</Text>
                </Pressable>
                <Pressable
                  onPress={() => canMoveDown && onMove(1)}
                  disabled={!canMoveDown}
                  style={({ pressed }) => [
                    modalStyles.actionBtn,
                    modalStyles.moveBtn,
                    !canMoveDown && modalStyles.moveBtnDisabled,
                    pressed && modalStyles.actionBtnPressed,
                  ]}>
                  <Ionicons
                    name="arrow-down"
                    size={18}
                    color={canMoveDown ? Palette.accentBright : Palette.textMuted}
                  />
                  <Text style={modalStyles.actionBtnText}>Move down</Text>
                </Pressable>
              </View>
            ) : null}
          </View>
          {/* No explicit Close button — the backdrop Pressable above
              dismisses on tap-outside (and Android system-back). */}
        </Pressable>
      </Pressable>
    </Modal>
  );
}

// ────────────────────────────────────────────────────────────────────────
// 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 (
    <View style={modalStyles.backCloseRow}>
      <Pressable
        onPress={onPress}
        style={({ pressed }) => [
          editorStyles.cancelBtn,
          editorStyles.backPill,
          pressed && modalStyles.actionBtnPressed,
        ]}>
        <Ionicons
          name="chevron-back"
          size={14}
          color={Palette.text}
          style={{ marginRight: 4 }}
        />
        <Text style={modalStyles.closeBtnText}>{label}</Text>
      </Pressable>
    </View>
  );
}

function InfoModal({
  visible, title, body, icon, onClose,
}: {
  visible: boolean;
  title: string;
  body: string;
  icon?: keyof typeof Ionicons.glyphMap;
  onClose: () => void;
}) {
  return (
    <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
      <Pressable style={modalStyles.backdrop} onPress={onClose}>
        <Pressable style={[modalStyles.sheet, infoModalStyles.sheet]} onPress={() => {}}>
          <View style={infoModalStyles.header}>
            {icon ? (
              <Ionicons name={icon} size={18} color={Palette.highlight} />
            ) : null}
            <Text style={modalStyles.title}>{title}</Text>
          </View>
          <Text style={infoModalStyles.body}>{body}</Text>
          <View style={modalStyles.explainCloseRow}>
            <View style={modalStyles.cycleBtnSpacer} />
            <Pressable
              onPress={onClose}
              style={({ pressed }) => [
                editorStyles.cancelBtn,
                pressed && modalStyles.actionBtnPressed,
              ]}>
              <Text style={modalStyles.closeBtnText}>Got it</Text>
            </Pressable>
            <View style={modalStyles.cycleBtnSpacer} />
          </View>
        </Pressable>
      </Pressable>
    </Modal>
  );
}

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<string>;
  icons: Record<string, string>;
  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 | null>(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<Browser | null>(null);

  useEffect(() => {
    setDraft(flow);
    // Reset transient editor state when opening a different Flow.
    setShowExplain(false);
    setExplainOverride(null);
  }, [flow]);

  if (!draft) {
    return <Modal visible={false} transparent onRequestClose={onClose}><View /></Modal>;
  }

  const browser = BROWSERS.find((b) => b.pkg === draft.browserPkg);

  const update = (patch: Partial<Flow>) => 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 (
    <Modal
      visible
      transparent
      animationType="fade"
      onRequestClose={onClose}>
      <Pressable style={modalStyles.backdrop} onPress={onClose}>
        <KeyboardAvoidingView
          behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
          style={modalStyles.keyboardWrap}
          pointerEvents="box-none">
        <Pressable style={modalStyles.sheetTall} onPress={() => {}}>
          {/* Header — big tappable browser icon (re-pick) + title +
              info-stats pill + close. */}
          <View style={editorStyles.editorHeader}>
            <Pressable
              onPress={() => setBrowserPickerOpen(true)}
              style={({ pressed }) => [
                editorStyles.editorHeaderIconWrap,
                pressed && modalStyles.actionBtnPressed,
              ]}>
              {browser && ASSET_ICONS[browser.id] ? (
                <Image
                  source={ASSET_ICONS[browser.id]}
                  style={editorStyles.editorHeaderIcon}
                />
              ) : (
                <View
                  style={[
                    editorStyles.editorHeaderIcon,
                    { backgroundColor: browser?.tint ?? Palette.bgElevated },
                  ]}
                />
              )}
            </Pressable>
            <View style={editorStyles.editorHeaderText}>
              <Text style={editorStyles.editorTitle}>
                {isExisting ? 'Edit Flow' : 'New Flow'}
              </Text>
              {browser ? (
                <Text style={editorStyles.editorSubtitle} numberOfLines={1}>
                  {browser.name}
                </Text>
              ) : null}
            </View>
            {browser ? (
              <Pressable
                onPress={() => setShowExplain(true)}
                hitSlop={6}
                style={({ pressed }) => [
                  editorStyles.infoStatsPill,
                  pressed && modalStyles.actionBtnPressed,
                ]}>
                <Ionicons
                  name="information-circle"
                  size={15}
                  color={Palette.accentBright}
                />
                <View style={editorStyles.infoStatsDivider} />
                <View style={editorStyles.infoStatsScore}>
                  <Ionicons name="shield-half-outline" size={11} color={Palette.textMuted} />
                  <Text style={editorStyles.selectScoreText}>{securityScore(browser)}</Text>
                </View>
                <View style={editorStyles.infoStatsDivider} />
                <View style={editorStyles.infoStatsScore}>
                  <Ionicons name="glasses-outline" size={11} color={Palette.textMuted} />
                  <Text style={editorStyles.selectScoreText}>{privacyScore(browser)}</Text>
                </View>
              </Pressable>
            ) : null}
            <Pressable
              onPress={onClose}
              hitSlop={10}
              style={({ pressed }) => [
                editorStyles.editorClose,
                pressed && modalStyles.actionBtnPressed,
              ]}>
              <Ionicons name="close" size={18} color={Palette.textMuted} />
            </Pressable>
          </View>

          <ScrollView
            style={[modalStyles.explainScroll, { maxHeight: scrollMaxH }]}
            contentContainerStyle={editorStyles.formBody}
            keyboardShouldPersistTaps="handled"
            persistentScrollbar
            showsVerticalScrollIndicator
            nestedScrollEnabled>
            {/* 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. */}
            <View style={editorStyles.fieldCard}>
              <Ionicons
                name="pricetag-outline"
                size={14}
                color={Palette.highlight}
              />
              <Text style={editorStyles.autoFireTitle}>Tag</Text>
              <Pressable
                onPress={() => setTagInfoOpen(true)}
                hitSlop={8}
                style={({ pressed }) => [
                  editorStyles.autoFireInfo,
                  pressed && modalStyles.actionBtnPressed,
                ]}>
                <Ionicons
                  name="information-circle-outline"
                  size={15}
                  color={Palette.textMuted}
                />
              </Pressable>
              <TextInput
                value={draft.title}
                onChangeText={(t) => update({ title: t })}
                placeholder="(optional)"
                placeholderTextColor={Palette.textMuted}
                style={editorStyles.fieldCardInput}
                autoCapitalize="sentences"
                autoCorrect={false}
              />
            </View>

            {/* Profile — single-row card: shield icon + label + segmented
                picker. Changing profile resets extras to profile defaults. */}
            <View style={editorStyles.fieldCard}>
              <Ionicons
                name="shield-outline"
                size={14}
                color={Palette.highlight}
              />
              <Text style={editorStyles.autoFireTitle}>Profile</Text>
              <View style={editorStyles.profilePickerRow}>
                {PROFILES.map((p) => {
                  const active = p === draft.profile;
                  return (
                    <Pressable
                      key={p}
                      onPress={() => {
                        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,
                      ]}>
                      <Text style={[
                        editorStyles.profilePickerBtnText,
                        active && editorStyles.profilePickerBtnTextActive,
                      ]}>
                        {PROFILE_LABEL[p]}
                      </Text>
                    </Pressable>
                  );
                })}
              </View>
            </View>

            {/* Autolaunch — single-row card: flash icon + label +
                (i) doc + iOS-style switch. No subtitle; the (i) opens
                the explanatory InfoModal when needed. */}
            <Pressable
              onPress={() => update({ autoFire: !draft.autoFire })}
              style={({ pressed }) => [
                editorStyles.autoFireRow,
                draft.autoFire && editorStyles.autoFireRowOn,
                pressed && modalStyles.actionBtnPressed,
              ]}>
              <Ionicons name="flash" size={14} color={Palette.highlight} />
              <Text style={editorStyles.autoFireTitle}>Autolaunch</Text>
              <Pressable
                onPress={() => setAutolaunchInfoOpen(true)}
                hitSlop={8}
                style={({ pressed }) => [
                  editorStyles.autoFireInfo,
                  pressed && modalStyles.actionBtnPressed,
                ]}>
                <Ionicons
                  name="information-circle-outline"
                  size={15}
                  color={Palette.textMuted}
                />
              </Pressable>
              {/* Flex spacer pushes the switch to the right while
                  keeping the info icon snug against the title. */}
              <View style={{ flex: 1 }} />
              <View
                style={[
                  editorStyles.switchTrack,
                  draft.autoFire && editorStyles.switchTrackOn,
                ]}>
                <View
                  style={[
                    editorStyles.switchThumb,
                    draft.autoFire && editorStyles.switchThumbOn,
                  ]}
                />
              </View>
            </Pressable>

            {/* 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. */}
            <View style={editorStyles.extrasPanel}>
              <View style={editorStyles.extrasPanelHeader}>
                <Text style={editorStyles.extrasPanelTitle}>Extras / Behaviour</Text>
                <Pressable
                  onPress={() => setExtraPickerOpen(true)}
                  hitSlop={6}
                  style={({ pressed }) => [
                    editorStyles.addNewChip,
                    pressed && modalStyles.actionBtnPressed,
                  ]}>
                  <Ionicons name="add" size={12} color={Palette.highlight} />
                  <Text style={editorStyles.addNewChipText}>Add new</Text>
                </Pressable>
              </View>
              <View style={editorStyles.extrasTable}>
              {draft.extras.length === 0 ? (
                <Text style={editorStyles.extrasEmpty}>None</Text>
              ) : 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 (
                <View key={i} style={editorStyles.extraRow}>
                  <View style={editorStyles.extraFields}>
                    <View style={editorStyles.extraKeyRow}>
                      <TextInput
                        value={e.key}
                        onChangeText={(v) => {
                          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 ? (
                        <Pressable
                          onPress={() =>
                            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,
                          ]}>
                          <Ionicons
                            name="information-circle"
                            size={14}
                            color={Palette.accentBright}
                          />
                        </Pressable>
                      ) : null}
                    </View>
                    <View style={editorStyles.extraValueRow}>
                      <Pressable
                        onPress={() => {
                          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,
                        ]}>
                        <Text style={editorStyles.typeChipText}>{e.type}</Text>
                      </Pressable>
                      {(() => {
                        // 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 (
                            <TextInput
                              value={e.value}
                              onChangeText={(v) => {
                                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 (
                          <Pressable
                            onPress={() => {
                              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,
                            ]}>
                            <Text style={editorStyles.extraValueDropdownText}>
                              {current.label}
                            </Text>
                            <Ionicons
                              name="chevron-down"
                              size={10}
                              color={Palette.accentBright}
                            />
                          </Pressable>
                        );
                      })()}
                    </View>
                  </View>
                  <Pressable
                    onPress={() => {
                      const next = [...draft.extras];
                      next.splice(i, 1);
                      update({ extras: next });
                    }}
                    hitSlop={6}
                    style={({ pressed }) => [
                      editorStyles.extraRemove,
                      pressed && modalStyles.actionBtnPressed,
                    ]}>
                    <Ionicons name="close" size={14} color={Palette.textMuted} />
                  </Pressable>
                </View>
                );
              })}
              </View>
            </View>
          </ScrollView>

          {/* Footer actions */}
          <View style={editorStyles.footer}>
            {isExisting ? (
              <Pressable
                onPress={() => onDelete(draft.id)}
                style={({ pressed }) => [
                  editorStyles.deleteBtn,
                  pressed && modalStyles.actionBtnPressed,
                ]}>
                <Ionicons name="trash-outline" size={14} color="#f0a0a0" />
                <Text style={editorStyles.deleteBtnText}>Delete</Text>
              </Pressable>
            ) : <View style={{ flex: 1 }} />}
            {/* 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 ? (
              <>
                <Pressable
                  onPress={() => canMoveUp && onMove(-1)}
                  disabled={!canMoveUp}
                  accessibilityLabel="Move flow up"
                  style={({ pressed }) => [
                    editorStyles.moveBtn,
                    !canMoveUp && editorStyles.moveBtnDisabled,
                    canMoveUp && pressed && modalStyles.actionBtnPressed,
                  ]}>
                  <Ionicons
                    name="arrow-up"
                    size={14}
                    color={canMoveUp ? Palette.accentBright : Palette.textMuted}
                  />
                </Pressable>
                <Pressable
                  onPress={() => canMoveDown && onMove(1)}
                  disabled={!canMoveDown}
                  accessibilityLabel="Move flow down"
                  style={({ pressed }) => [
                    editorStyles.moveBtn,
                    !canMoveDown && editorStyles.moveBtnDisabled,
                    canMoveDown && pressed && modalStyles.actionBtnPressed,
                  ]}>
                  <Ionicons
                    name="arrow-down"
                    size={14}
                    color={canMoveDown ? Palette.accentBright : Palette.textMuted}
                  />
                </Pressable>
              </>
            ) : null}
            <Pressable
              onPress={onClose}
              style={({ pressed }) => [
                editorStyles.cancelBtn,
                pressed && modalStyles.actionBtnPressed,
              ]}>
              <Text style={modalStyles.closeBtnText}>Cancel</Text>
            </Pressable>
            <Pressable
              onPress={() => onSave({ ...draft, mode: deriveModeFromExtras(draft.extras) })}
              style={({ pressed }) => [
                editorStyles.saveBtn,
                pressed && modalStyles.actionBtnPressed,
              ]}>
              <Text style={editorStyles.saveBtnText}>Save</Text>
            </Pressable>
          </View>
        </Pressable>
        </KeyboardAvoidingView>
      </Pressable>

      <BrowserPicker
        visible={browserPickerOpen}
        installed={installed}
        icons={icons}
        selected={draft.browserPkg}
        onPick={(pkg) => {
          update({ browserPkg: pkg });
          setBrowserPickerOpen(false);
        }}
        onClose={() => setBrowserPickerOpen(false)}
      />

      <ExtraPicker
        visible={extraPickerOpen}
        suggestions={suggestionsFor(draft.browserPkg).filter(
          // Hide curated suggestions the Flow already carries — adding
          // them twice would just duplicate the entry.
          (s) => !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)}
      />

      <InfoModal
        visible={autolaunchInfoOpen}
        title="Autolaunch"
        icon="flash"
        body={
          'When Warden receives a shared link, this Flow fires instantly '
          + 'without showing the menu. Only one Flow can be set as the '
          + 'Autolaunch target — enabling it here clears it from any '
          + 'other Flow.'
        }
        onClose={() => setAutolaunchInfoOpen(false)}
      />

      <InfoModal
        visible={tagInfoOpen}
        title="Tag"
        icon="pricetag-outline"
        body={
          'A short free-form label. When set, it appears as a small pill '
          + "next to the browser's name on the Flow row — handy for "
          + 'telling apart multiple Flows that use the same browser '
          + '(e.g. "Work" vs "Throwaway"). Leave it blank to show just '
          + 'the browser name.'
        }
        onClose={() => setTagInfoOpen(false)}
      />

      <PrivacyExplanationModal
        browser={showExplain ? (explainOverride ?? browser ?? null) : null}
        list={BROWSERS}
        onNavigate={(b) => setExplainOverride(b)}
        onClose={() => { setShowExplain(false); setExplainOverride(null); }}
      />
    </Modal>
  );
}

/**
 * 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 (
    <Pressable
      onPress={onPress}
      hitSlop={4}
      style={({ pressed }) => [
        editorStyles.sortHeader,
        { justifyContent: align },
        style,
        pressed && modalStyles.actionBtnPressed,
      ]}>
      {icon ? (
        <Ionicons
          name={icon}
          size={11}
          color={active ? Palette.accentBright : Palette.textMuted}
          style={{ marginRight: 3 }}
        />
      ) : null}
      <Text
        style={[
          editorStyles.tableHeaderText,
          active && editorStyles.tableHeaderTextActive,
        ]}>
        {label}
      </Text>
      {active ? (
        <Ionicons
          name={dir === 'desc' ? 'chevron-down' : 'chevron-up'}
          size={11}
          color={Palette.accentBright}
          style={{ marginLeft: 2 }}
        />
      ) : null}
    </Pressable>
  );
}

function BrowserPicker({
  visible, installed, icons, selected, onPick, onClose,
}: {
  visible: boolean;
  installed: Set<string>;
  icons: Record<string, string>;
  /** 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<string>(selected);
  useEffect(() => {
    if (visible) setPending(selected);
  }, [visible, selected]);
  const [explainBrowser, setExplainBrowser] = useState<Browser | null>(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<SortKey | null>(null);
  const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
  const defaultDir: Record<SortKey, 'asc' | 'desc'> = {
    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 (
    <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
      <Pressable style={modalStyles.backdrop} onPress={onClose}>
        <Pressable
          style={[modalStyles.sheetTall, editorStyles.pickerSheet]}
          onPress={() => {}}>
          <View style={editorStyles.pickerTitleRow}>
            <Text style={[modalStyles.title, editorStyles.pickerTitle]}>
              Select browser
            </Text>
            <Pressable
              onPress={() => setGradesInfoOpen(true)}
              hitSlop={8}
              accessibilityLabel="About the scores"
              style={({ pressed }) => [
                editorStyles.pickerTitleInfo,
                pressed && modalStyles.actionBtnPressed,
              ]}>
              <Ionicons
                name="information-circle-outline"
                size={18}
                color={Palette.textMuted}
              />
            </Pressable>
          </View>

          {/* 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. */}
          <View style={editorStyles.tableHeader}>
            <SortHeader
              label="Browser"
              active={sortKey === 'browser'}
              dir={sortDir}
              onPress={() => cycleSort('browser')}
              style={editorStyles.colBrowser}
            />
            <View style={editorStyles.colSeparator} />
            <SortHeader
              label="Sec"
              active={sortKey === 'security'}
              dir={sortDir}
              onPress={() => cycleSort('security')}
              style={editorStyles.colScore}
              icon="shield-half-outline"
            />
            <View style={editorStyles.colSeparator} />
            <SortHeader
              label="Priv"
              active={sortKey === 'privacy'}
              dir={sortDir}
              onPress={() => cycleSort('privacy')}
              style={editorStyles.colScore}
              icon="glasses-outline"
            />
          </View>

          <ScrollView
            style={editorStyles.pickerScrollFlex}
            // Native scrollbar — see PrivacyExplanationModal for why
            // the custom Animated.Value scrollbar was retired.
            showsVerticalScrollIndicator
            persistentScrollbar
            indicatorStyle="white"
            nestedScrollEnabled>
            {list.length === 0 ? (
              <Text style={styles.emptyHint}>— No installed browsers —</Text>
            ) : (
              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 (
                <View key={b.id} style={editorStyles.pickerRowWrap}>
                <Pressable
                  onPress={() => setPending(b.pkg)}
                  onLongPress={() => setExplainBrowser(b)}
                  delayLongPress={350}
                  style={({ pressed }) => [
                    editorStyles.pickerRow,
                    featured && editorStyles.pickerRowFeatured,
                    b.pkg === pending && editorStyles.pickerRowSelected,
                    pressed && modalStyles.actionBtnPressed,
                  ]}>
                  <View style={[editorStyles.colBrowser, editorStyles.cellBrowser]}>
                    <View style={notInstalled ? editorStyles.pickerIconDim : null}>
                      {ASSET_ICONS[b.id] ? (
                        <Image source={ASSET_ICONS[b.id]} style={editorStyles.pickerIcon} />
                      ) : icons[b.pkg] ? (
                        <Image source={{ uri: icons[b.pkg] }} style={editorStyles.pickerIcon} />
                      ) : (
                        <View style={[editorStyles.pickerIcon, { backgroundColor: b.tint }]} />
                      )}
                    </View>
                    <View style={editorStyles.pickerNameCol}>
                      {/* Line 1: browser name + inline recommendation pill. */}
                      <View style={editorStyles.pickerNameRow}>
                        <Text
                          style={[
                            editorStyles.pickerName,
                            notInstalled && editorStyles.pickerNameDim,
                          ]}
                          numberOfLines={1}>
                          {formatRunicName(b.name, runicNames)}
                        </Text>
                        {BROWSER_RECOMMENDATION[b.id] ? (
                          <Text
                            style={[
                              editorStyles.recoInline,
                              notInstalled && editorStyles.recoInlineDim,
                            ]}
                            numberOfLines={1}>
                            {BROWSER_RECOMMENDATION[b.id]}
                          </Text>
                        ) : null}
                        {BROWSER_HAS_EXTENSIONS[b.id] ? (
                          <Ionicons
                            name="extension-puzzle-outline"
                            size={12}
                            color={Palette.textMuted}
                            style={[
                              editorStyles.extensionsIcon,
                              notInstalled && editorStyles.extensionsIconDim,
                            ]}
                          />
                        ) : null}
                      </View>
                      {/* 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. */}
                      <Text
                        style={[
                          editorStyles.pickerSubline,
                          notInstalled && editorStyles.notInstalledTag,
                        ]}
                        numberOfLines={1}>
                        {notInstalled
                          ? BROWSER_AVAILABILITY[b.id]
                            ? `Not installed, ${BROWSER_AVAILABILITY[b.id].short}`
                            : 'Not installed'
                          : ' '}
                      </Text>
                    </View>
                  </View>
                  {/* 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 (
                      <View style={editorStyles.scorePill}>
                        <View style={editorStyles.scorePillHalf}>
                          <Ionicons
                            name={accent?.security ? 'shield-half' : 'shield-half-outline'}
                            size={11}
                            color={accent?.security ? GOLD : Palette.textMuted}
                            style={{ marginRight: 3 }}
                          />
                          <Text style={editorStyles.selectScoreText}>{securityScore(b)}</Text>
                        </View>
                        <View style={editorStyles.scorePillDivider} />
                        <View style={editorStyles.scorePillHalf}>
                          <Ionicons
                            name={accent?.privacy ? 'glasses' : 'glasses-outline'}
                            size={11}
                            color={accent?.privacy ? GOLD : Palette.textMuted}
                            style={{ marginRight: 3 }}
                          />
                          <Text style={editorStyles.selectScoreText}>{privacyScore(b)}</Text>
                        </View>
                      </View>
                    );
                  })()}
                </Pressable>
                {b.pkg === pending ? (
                  <View
                    style={editorStyles.pickerRowBottomCover}
                    pointerEvents="none"
                  />
                ) : null}
                </View>
                );
              })
            )}
          </ScrollView>
          {/* 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. */}
          <Text
            style={[
              editorStyles.pickerWarning,
              !(pending && !installed.has(pending)) && editorStyles.pickerWarningHidden,
            ]}>
            {pending && !installed.has(pending)
              ? 'This browser is not installed — install it first to continue.'
              : ' '}
          </Text>
          <View style={editorStyles.pickerFooter}>
            <Pressable
              onPress={onClose}
              style={({ pressed }) => [
                editorStyles.cancelBtn,
                pressed && modalStyles.actionBtnPressed,
              ]}>
              <Text style={modalStyles.closeBtnText}>Cancel</Text>
            </Pressable>
            {pending && installed.has(pending) ? (
              <Pressable
                onPress={() => onPick(pending)}
                style={({ pressed }) => [
                  editorStyles.saveBtn,
                  pressed && modalStyles.actionBtnPressed,
                ]}>
                <Text style={editorStyles.saveBtnText}>Continue</Text>
              </Pressable>
            ) : 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 ? (
              <View style={editorStyles.showInfoCenter} pointerEvents="box-none">
                <Pressable
                  onPress={() => {
                    const b = BROWSERS.find((x) => x.pkg === pending);
                    if (b) setExplainBrowser(b);
                  }}
                  style={({ pressed }) => [
                    editorStyles.showInfoBtn,
                    pressed && modalStyles.actionBtnPressed,
                  ]}>
                  <Ionicons
                    name="information-circle-outline"
                    size={14}
                    color={Palette.accentBright}
                  />
                  <Text style={editorStyles.showInfoBtnText}>Show info</Text>
                </Pressable>
              </View>
            ) : null}
          </View>
        </Pressable>
      </Pressable>

      {/* 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. */}
      <PrivacyExplanationModal
        browser={explainBrowser}
        canSelect={false}
        list={list}
        onNavigate={setExplainBrowser}
        onClose={() => setExplainBrowser(null)}
      />

      <InfoModal
        visible={gradesInfoOpen}
        title="About the scores"
        icon="ribbon-outline"
        body={
          'Security and Privacy are graded out of 100 from the best '
          + 'public information we have: privacytests.org test passes, '
          + "each browser's documented protections, and the strength of "
          + 'the private-mode intent it actually honours. They are '
          + 'opinions — useful for comparison, not a substitute for your '
          + 'own threat model. Open a browser to read the full breakdown.'
        }
        onClose={() => setGradesInfoOpen(false)}
      />
    </Modal>
  );
}

/**
 * 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 (
    <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
      <Pressable style={modalStyles.backdrop} onPress={onClose}>
        <Pressable style={modalStyles.sheet} onPress={() => {}}>
          <Text style={modalStyles.title}>Add extra</Text>
          <ScrollView style={editorStyles.pickerScroll}>
            {/* 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 ? (
              <>
                <Pressable
                  onPress={() => setConfirmMax(true)}
                  style={({ pressed }) => [
                    editorStyles.maxPrivacyRow,
                    pressed && modalStyles.actionBtnPressed,
                  ]}>
                  <Ionicons name="shield-checkmark" size={14} color={Palette.text} />
                  <Text style={editorStyles.maxPrivacyRowLabel}>Set max privacy</Text>
                  <Ionicons name="chevron-forward" size={12} color={Palette.text} />
                </Pressable>
                <View style={editorStyles.pickerDivider} />
              </>
            ) : null}

            {suggestions.map((s) => (
              <Pressable
                key={s.key}
                onPress={() => onPickSuggestion(s)}
                style={({ pressed }) => [
                  editorStyles.suggestionRow,
                  pressed && modalStyles.actionBtnPressed,
                ]}>
                <Text style={editorStyles.suggestionLabel}>{s.label}</Text>
                <Text style={editorStyles.suggestionKey} numberOfLines={1}>
                  {s.key}
                </Text>
                {s.hint ? (
                  <Text style={editorStyles.suggestionHint}>{s.hint}</Text>
                ) : null}
              </Pressable>
            ))}

            <Pressable
              onPress={onPickCustom}
              style={({ pressed }) => [
                editorStyles.suggestionRow,
                editorStyles.customRow,
                pressed && modalStyles.actionBtnPressed,
              ]}>
              <Text style={editorStyles.suggestionLabel}>Custom…</Text>
              <Text style={editorStyles.suggestionHint}>Type any key + value</Text>
            </Pressable>
          </ScrollView>
          <Pressable
            onPress={onClose}
            style={({ pressed }) => [
              modalStyles.closeBtn,
              pressed && modalStyles.actionBtnPressed,
            ]}>
            <Text style={modalStyles.closeBtnText}>Cancel</Text>
          </Pressable>
        </Pressable>
      </Pressable>

      {/* Confirmation modal — stacks on top of the picker, lists the
          exact key/type/value triples the recipe will write. */}
      <Modal
        visible={confirmMax}
        transparent
        animationType="fade"
        onRequestClose={() => setConfirmMax(false)}>
        <Pressable
          style={modalStyles.backdrop}
          onPress={() => setConfirmMax(false)}>
          <Pressable style={modalStyles.sheet} onPress={() => {}}>
            <View style={modalStyles.header}>
              <View
                style={[
                  modalStyles.headerIcon,
                  { alignItems: 'center', justifyContent: 'center', backgroundColor: Palette.accent },
                ]}>
                <Ionicons name="shield-checkmark" size={22} color={Palette.text} />
              </View>
              <View style={modalStyles.headerText}>
                <Text style={modalStyles.title}>Set max privacy</Text>
                <Text style={modalStyles.subtitle}>
                  {maxPrivacyPreview.length} extra
                  {maxPrivacyPreview.length === 1 ? '' : 's'} will be applied
                </Text>
              </View>
            </View>
            <Text style={modalStyles.summary}>
              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.
            </Text>
            <ScrollView style={editorStyles.maxPrivacyPreviewScroll}>
              {maxPrivacyPreview.length === 0 ? (
                <Text style={editorStyles.extrasEmpty}>
                  This browser has no canonical max-privacy recipe.
                </Text>
              ) : (
                maxPrivacyPreview.map((e, i) => {
                  const doc = lookupSuggestion(e.key);
                  return (
                    <View key={i} style={editorStyles.previewRow}>
                      <Text style={editorStyles.previewKey} numberOfLines={2}>
                        {e.key}
                      </Text>
                      <View style={editorStyles.previewMeta}>
                        <Text style={editorStyles.previewType}>{e.type}</Text>
                        <Text style={editorStyles.previewValue}>
                          = {e.value === '' ? '""' : e.value}
                        </Text>
                      </View>
                      {doc?.label ? (
                        <Text style={editorStyles.previewLabel}>{doc.label}</Text>
                      ) : null}
                    </View>
                  );
                })
              )}
              {/* 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 ? (
                <View style={[editorStyles.previewRow, editorStyles.previewRowUnset]}>
                  <Text style={editorStyles.previewKey} numberOfLines={2}>
                    {REFERRER_EXTRA_KEY}
                  </Text>
                  <View style={editorStyles.previewMeta}>
                    <Text style={editorStyles.previewType}>unset</Text>
                    <Text style={editorStyles.previewValue}>
                      = stripped (Uri.EMPTY)
                    </Text>
                  </View>
                  <Text style={editorStyles.previewLabel}>
                    Referrer override removed — the app auto-strips referrer.
                  </Text>
                </View>
              ) : null}
            </ScrollView>
            <View style={editorStyles.footer}>
              <View style={{ flex: 1 }} />
              <Pressable
                onPress={() => setConfirmMax(false)}
                style={({ pressed }) => [
                  editorStyles.cancelBtn,
                  pressed && modalStyles.actionBtnPressed,
                ]}>
                <Text style={modalStyles.closeBtnText}>Cancel</Text>
              </Pressable>
              <Pressable
                onPress={() => {
                  setConfirmMax(false);
                  onSetMaxPrivacy();
                }}
                disabled={maxPrivacyPreview.length === 0}
                style={({ pressed }) => [
                  editorStyles.saveBtn,
                  maxPrivacyPreview.length === 0 && { opacity: 0.4 },
                  pressed && modalStyles.actionBtnPressed,
                ]}>
                <Text style={editorStyles.saveBtnText}>Apply</Text>
              </Pressable>
            </View>
          </Pressable>
        </Pressable>
      </Modal>
    </Modal>
  );
}

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 (
    <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
      <View style={modalStyles.backdrop} pointerEvents="box-none">
        <Pressable style={modalStyles.backdropFill} onPress={onClose} accessibilityLabel="Close glossary" />
        <View style={modalStyles.sheetTall}>
          <View style={modalStyles.headerSticky}>
            <View style={[modalStyles.headerIcon, modalStyles.glossaryHeaderIcon]}>
              <Ionicons name="book-outline" size={22} color={Palette.highlight} />
            </View>
            <View style={modalStyles.headerText}>
              <Text style={modalStyles.encTitle}>Glossary</Text>
              <Text style={modalStyles.encSubtitle}>Terms explained</Text>
            </View>
          </View>
          <ScrollView
            style={[modalStyles.explainScrollInWrap, { maxHeight: scrollMaxH }]}
            contentContainerStyle={modalStyles.explainScrollContent}
            showsVerticalScrollIndicator
            persistentScrollbar
            indicatorStyle="white"
            nestedScrollEnabled>
            {groups.map((g) => (
              <View key={g.axis} style={modalStyles.section}>
                <View style={modalStyles.glossaryGroupHeader}>
                  <Ionicons name={g.icon} size={14} color={Palette.accentBright} />
                  <Text style={modalStyles.sectionTitle}>{g.label}</Text>
                </View>
                {glossaryFor(g.axis).map((entry) => (
                  <View key={entry.term} style={modalStyles.glossaryEntry}>
                    <Text style={modalStyles.glossaryTerm}>{entry.term}</Text>
                    <Text style={modalStyles.glossaryDef}>{entry.def}</Text>
                  </View>
                ))}
              </View>
            ))}
          </ScrollView>
          <View style={modalStyles.explainCloseRow}>
            <View style={modalStyles.cycleBtnSpacer} />
            <Pressable
              onPress={onClose}
              style={({ pressed }) => [editorStyles.cancelBtn, pressed && modalStyles.actionBtnPressed]}>
              <Text style={modalStyles.closeBtnText}>Close</Text>
            </Pressable>
            <View style={modalStyles.cycleBtnSpacer} />
          </View>
        </View>
      </View>
    </Modal>
  );
}

/**
 * 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<Record<string, boolean>>({});
  useEffect(() => { if (visible) setChecked({}); }, [visible, browser?.id]);
  if (!browser) {
    return <Modal visible={false} transparent onRequestClose={onClose}><View /></Modal>;
  }
  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 (
    <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
      <View style={modalStyles.backdrop} pointerEvents="box-none">
        <Pressable style={modalStyles.backdropFill} onPress={onClose} accessibilityLabel="Close checklist" />
        <View style={modalStyles.sheetTall}>
          <View style={modalStyles.headerSticky}>
            <View style={[modalStyles.headerIcon, modalStyles.glossaryHeaderIcon]}>
              <Ionicons name="checkbox-outline" size={22} color={Palette.highlight} />
            </View>
            <View style={modalStyles.headerText}>
              <Text style={modalStyles.encTitle}>Checklist</Text>
              <Text style={modalStyles.encSubtitle}>
                {browser.name} · {done}/{total} done
              </Text>
            </View>
          </View>
          <ScrollView
            style={[modalStyles.explainScrollInWrap, { maxHeight: scrollMaxH }]}
            contentContainerStyle={modalStyles.explainScrollContent}
            showsVerticalScrollIndicator
            persistentScrollbar
            indicatorStyle="white"
            nestedScrollEnabled>
            {groups.map((g) => (
              <View key={g.label} style={modalStyles.section}>
                <View style={modalStyles.glossaryGroupHeader}>
                  <Ionicons name={g.icon} size={14} color={Palette.accentBright} />
                  <Text style={modalStyles.sectionTitle}>{g.label}</Text>
                </View>
                {g.items.map((item, i) => {
                  const key = `${g.label}:${i}`;
                  const on = !!checked[key];
                  return (
                    <Pressable
                      key={key}
                      onPress={() => setChecked((c) => ({ ...c, [key]: !c[key] }))}
                      style={({ pressed }) => [
                        modalStyles.checklistRow,
                        pressed && modalStyles.actionBtnPressed,
                      ]}>
                      <Ionicons
                        name={on ? 'checkbox' : 'square-outline'}
                        size={18}
                        color={on ? Palette.highlight : Palette.textMuted}
                        style={{ marginTop: 1 }}
                      />
                      <Text
                        style={[
                          modalStyles.checklistText,
                          on && modalStyles.checklistTextDone,
                        ]}>
                        {item}
                      </Text>
                    </Pressable>
                  );
                })}
              </View>
            ))}
            <Text style={modalStyles.encOptimNote}>
              Tick items off as you apply them in {browser.name}. (Progress isn’t saved yet.)
            </Text>
          </ScrollView>
          <View style={modalStyles.explainCloseRow}>
            <View style={modalStyles.cycleBtnSpacer} />
            <Pressable
              onPress={onClose}
              style={({ pressed }) => [editorStyles.cancelBtn, pressed && modalStyles.actionBtnPressed]}>
              <Text style={modalStyles.closeBtnText}>Close</Text>
            </Pressable>
            <View style={modalStyles.cycleBtnSpacer} />
          </View>
        </View>
      </View>
    </Modal>
  );
}

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 (
    <Modal
      visible={browser !== null}
      transparent
      animationType="fade"
      onRequestClose={onClose}>
      {/* 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. */}
      <View style={modalStyles.backdrop} pointerEvents="box-none">
        <Pressable
          style={modalStyles.backdropFill}
          onPress={onClose}
          accessibilityLabel="Close modal"
        />
        <View style={modalStyles.sheetTall}>
          {browser && explanation ? (
            <>
              {/* Sticky header — outside the ScrollView so it stays
                  visible while the user scrolls the writeup. */}
              <View style={modalStyles.headerSticky}>
                {ASSET_ICONS[browser.id] ? (
                  <Image source={ASSET_ICONS[browser.id]} style={modalStyles.headerIcon} />
                ) : (
                  <View
                    style={[modalStyles.headerIcon, { backgroundColor: browser.tint }]}
                  />
                )}
                <View style={modalStyles.headerText}>
                  <Text style={modalStyles.encTitle}>{browser.name}</Text>
                  <Text style={modalStyles.encSubtitle}>Encyclopedia</Text>
                </View>
                {onSelect && canSelect ? (
                  <Pressable
                    onPress={onSelect}
                    style={({ pressed }) => [
                      modalStyles.headerSelectPill,
                      pressed && modalStyles.actionBtnPressed,
                    ]}>
                    <Ionicons name="checkmark" size={13} color={Palette.text} />
                    <Text style={modalStyles.headerSelectText}>Select</Text>
                  </Pressable>
                ) : null}
                {/* Top-right close — replaces the bottom Close button. */}
                <Pressable
                  onPress={onClose}
                  hitSlop={10}
                  accessibilityLabel="Close"
                  style={({ pressed }) => [
                    modalStyles.headerCloseBtn,
                    pressed && modalStyles.actionBtnPressed,
                  ]}>
                  <Ionicons name="close" size={20} color={Palette.textMuted} />
                </Pressable>
              </View>
              {/* 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. */}
              <Text style={modalStyles.summary}>{explanation.summary}</Text>
              <View style={modalStyles.infoPillsRow}>
                {BROWSER_AVAILABILITY[browser.id] ? (
                  <View style={modalStyles.availabilityPill}>
                    <Ionicons name="information-circle" size={13} color={Palette.accentBright} />
                    <Text style={modalStyles.availabilityPillText}>
                      {BROWSER_AVAILABILITY[browser.id].long}
                    </Text>
                  </View>
                ) : null}
                {BROWSER_HAS_EXTENSIONS[browser.id] ? (
                  <View style={modalStyles.capabilityPill}>
                    <Ionicons name="extension-puzzle-outline" size={13} color={Palette.accentBright} />
                    <Text style={modalStyles.capabilityPillText}>Supports extensions</Text>
                  </View>
                ) : null}
              </View>

              {/* Action launchers above the tabs — Glossary (left)
                  and Configuration checklist (right beside it). */}
              <View style={modalStyles.encGlossaryRow}>
                <Pressable
                  onPress={() => setGlossaryOpen(true)}
                  hitSlop={8}
                  style={({ pressed }) => [
                    modalStyles.encGlossaryBtn,
                    pressed && modalStyles.actionBtnPressed,
                  ]}>
                  <Ionicons name="book-outline" size={13} color={Palette.accentBright} />
                  <Text style={modalStyles.encGlossaryBtnText}>Glossary</Text>
                </Pressable>
                <Pressable
                  onPress={() => setChecklistOpen(true)}
                  hitSlop={8}
                  style={({ pressed }) => [
                    modalStyles.encGlossaryBtn,
                    pressed && modalStyles.actionBtnPressed,
                  ]}>
                  <Ionicons name="checkbox-outline" size={13} color={Palette.accentBright} />
                  <Text style={modalStyles.encGlossaryBtnText}>Checklist</Text>
                </Pressable>
              </View>

              {/* 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. */}
              <View style={modalStyles.encTabStrip}>
                {(['security', 'privacy'] as const).map((t) => (
                  <Pressable
                    key={t}
                    onPress={() => setMainTab(t)}
                    style={({ pressed }) => [
                      modalStyles.encTab,
                      mainTab === t && modalStyles.encTabActive,
                      pressed && modalStyles.actionBtnPressed,
                    ]}>
                    <Ionicons
                      name={t === 'security' ? 'shield-half-outline' : 'glasses-outline'}
                      size={14}
                      color={mainTab === t ? Palette.text : Palette.textMuted}
                    />
                    <Text
                      style={[
                        modalStyles.encTabText,
                        mainTab === t && modalStyles.encTabTextActive,
                      ]}>
                      {t === 'security' ? 'Security' : 'Privacy'}
                    </Text>
                  </Pressable>
                ))}
              </View>

              <ScrollView
                style={[modalStyles.explainScrollInWrap, { maxHeight: scrollMaxH }]}
                contentContainerStyle={modalStyles.explainScrollContent}
                showsVerticalScrollIndicator
                persistentScrollbar
                indicatorStyle="white"
                nestedScrollEnabled>
                <ScoreBreakdown browser={browser} only={mainTab} />

                {/* Privacy tab carries the privacytests breakdown,
                    built-in protections, and supported-intent rows.
                    Security tab stops at the score breakdown. */}
                {mainTab === 'privacy' ? (
                  <>
                    {explanation.testScore ? (
                      <View style={modalStyles.section}>
                        <Text style={modalStyles.sectionTitle}>Privacytests.org score</Text>
                        <Text style={modalStyles.scoreLine}>
                          <Text style={modalStyles.scoreBig}>{explanation.testScore.totalPassed}</Text>
                          <Text style={modalStyles.bulletText}>
                            {' '}/ {explanation.testScore.totalTests} tests passed
                          </Text>
                        </Text>
                        <Text style={modalStyles.scoreVersion}>
                          v{explanation.testScore.version} · snapshot {PRIVACY_TEST_DATE}
                        </Text>
                        <View style={modalStyles.testCategoryGrid}>
                          {(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 (
                                <View key={key} style={modalStyles.testCategoryRow}>
                                  <Text style={modalStyles.testCategoryLabel}>
                                    {TEST_CATEGORY_LABEL[key] ?? key}
                                  </Text>
                                  <View style={modalStyles.testCategoryTrack}>
                                    <View style={[modalStyles.testCategoryFill, { width: `${pct}%`, backgroundColor: colour }]} />
                                  </View>
                                  <Text style={modalStyles.testCategoryRatio}>{v.passed}/{v.total}</Text>
                                </View>
                              );
                            })}
                        </View>
                      </View>
                    ) : null}

                    {explanation.protections.length > 0 ? (
                      <View style={modalStyles.section}>
                        <Text style={modalStyles.sectionTitle}>Browser protections</Text>
                        {explanation.protections.map((line, i) => (
                          <View key={i} style={modalStyles.bulletRow}>
                            <Text style={modalStyles.bullet}>·</Text>
                            <Text style={modalStyles.bulletText}>{line}</Text>
                          </View>
                        ))}
                      </View>
                    ) : null}

                    <Text style={modalStyles.sectionTitleSolo}>Supported intents</Text>
                    {miniMode ? (
                      <View style={modalStyles.intentRow}>
                        <View style={modalStyles.intentHeader}>
                          <Ionicons
                            name={miniMode.supported ? 'checkmark-circle' : 'close-circle-outline'}
                            size={16}
                            color={miniMode.supported ? Palette.accentBright : Palette.textMuted}
                          />
                          <Text style={modalStyles.intentLabel}>Mini-tab</Text>
                          <Text style={modalStyles.intentBadge}>
                            {miniMode.supported ? 'Supported' : 'Not supported'}
                          </Text>
                        </View>
                        <Text style={modalStyles.intentDesc}>{miniMode.description}</Text>
                      </View>
                    ) : null}
                    {privateModeRow ? (
                      <View style={modalStyles.intentRow}>
                        <View style={modalStyles.intentHeader}>
                          <Ionicons
                            name={privateModeRow.supported ? 'checkmark-circle' : 'close-circle-outline'}
                            size={16}
                            color={privateModeRow.supported ? Palette.accentBright : Palette.textMuted}
                          />
                          <Text style={modalStyles.intentLabel}>Incognito / Isolation</Text>
                          <Text style={modalStyles.intentBadge}>
                            {privateModeRow.supported ? 'Supported' : 'Not supported'}
                          </Text>
                        </View>
                        <Text style={modalStyles.intentDesc}>{privateModeRow.description}</Text>
                      </View>
                    ) : null}
                  </>
                ) : null}

                {/* Optimization (hardening) tips — appended at the
                    bottom of each tab's content. */}
                <View style={modalStyles.section}>
                  <Text style={modalStyles.sectionTitle}>
                    {mainTab === 'security' ? 'Harden security' : 'Harden privacy'}
                  </Text>
                  {(mainTab === 'security' ? securityTips(browser) : privacyTips(browser)).map(
                    (tip, i) => (
                      <View key={i} style={modalStyles.bulletRow}>
                        <Ionicons
                          name="caret-forward"
                          size={11}
                          color={Palette.highlight}
                          style={{ marginTop: 2 }}
                        />
                        <Text style={modalStyles.bulletText}>{tip}</Text>
                      </View>
                    ),
                  )}
                  <Text style={modalStyles.encOptimNote}>
                    Settings live inside {browser.name} itself — Warden documents them, it doesn’t apply them.
                  </Text>
                </View>
              </ScrollView>
              {/* 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 ? (
                <View style={modalStyles.explainCloseRow}>
                  <Pressable
                    onPress={goPrev ?? undefined}
                    hitSlop={10}
                    accessibilityLabel="Previous browser"
                    style={({ pressed }) => [
                      modalStyles.cycleBtn,
                      pressed && modalStyles.actionBtnPressed,
                    ]}>
                    <Ionicons name="chevron-back" size={18} color={Palette.text} />
                  </Pressable>
                  <Pressable
                    onPress={goNext ?? undefined}
                    hitSlop={10}
                    accessibilityLabel="Next browser"
                    style={({ pressed }) => [
                      modalStyles.cycleBtn,
                      pressed && modalStyles.actionBtnPressed,
                    ]}>
                    <Ionicons name="chevron-forward" size={18} color={Palette.text} />
                  </Pressable>
                </View>
              ) : null}
            </>
          ) : null}
        </View>
      </View>
      {/* Standalone glossary modal — all terms grouped by axis. */}
      <GlossaryModal visible={glossaryOpen} onClose={() => setGlossaryOpen(false)} />
      {/* Configuration checklist — recommended hardening settings as
          a to-do. Local check state only (no persistence yet). */}
      <ConfigChecklistModal
        browser={browser}
        visible={checklistOpen}
        onClose={() => setChecklistOpen(false)}
      />
    </Modal>
  );
}

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 (
    <SafeAreaProvider>
      <Home />
      <StatusBar style="light" />
    </SafeAreaProvider>
  );
}

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 },
});
