App.tsx raw

   1  import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
   2  import * as Clipboard from 'expo-clipboard';
   3  import * as IntentLauncher from 'expo-intent-launcher';
   4  import * as Linking from 'expo-linking';
   5  import { StatusBar } from 'expo-status-bar';
   6  import React, { useEffect, useMemo, useRef, useState } from 'react';
   7  import {
   8    Alert,
   9    Animated,
  10    AppState,
  11    BackHandler,
  12    Image,
  13    Keyboard,
  14    KeyboardAvoidingView,
  15    Modal,
  16    Platform,
  17    Pressable,
  18    ScrollView,
  19    Share,
  20    StyleSheet,
  21    Text,
  22    TextInput,
  23    ToastAndroid,
  24    View,
  25    Easing,
  26    useWindowDimensions,
  27  } from 'react-native';
  28  import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context';
  29  
  30  import {
  31    BROWSERS,
  32    explainPrivacy,
  33    type Browser,
  34    type PrivacyMode,
  35  } from '@/constants/browsers';
  36  import { PRIVACY_TEST_DATE } from '@/constants/privacy-scores';
  37  import {
  38    privacyBreakdown,
  39    privacyScore,
  40    securityBreakdown,
  41    securityScore,
  42  } from '@/constants/ratings';
  43  import { securityTips, privacyTips } from '@/constants/optimization';
  44  import { glossaryFor } from '@/constants/glossary';
  45  import { BUILD_COMMIT, BUILD_VERSION } from '@/constants/build-info';
  46  import {
  47    EXTRA_SUGGESTIONS,
  48    INCOGNITO_KEYS,
  49    MODE_MANAGED_KEYS,
  50    PROFILES,
  51    PROFILE_LABEL,
  52    deriveModeFromExtras,
  53    defaultsForProfile,
  54    extrasForMode,
  55    modesForProfile,
  56    REFERRER_EXTRA_KEY,
  57    isMaxPrivacy,
  58    loadFlows,
  59    lookupSuggestion,
  60    newFlowId,
  61    saveFlows,
  62    suggestionsFor,
  63    syncExtrasWithMode,
  64    type ExtraSuggestion,
  65    type Flow,
  66    type FlowExtra,
  67    type Profile,
  68  } from '@/constants/flows';
  69  import { Fonts, Palette } from '@/constants/theme';
  70  import {
  71    SITE_TRACKING_RULES,
  72    TRACKING_PARAMS,
  73    TRACKING_PREFIXES,
  74    stripTrackingParams,
  75  } from '@/constants/tracking-params';
  76  import { fullyUnwrapRedirect } from '@/constants/redirect-unwrap';
  77  import { checkLinkLeaks, leakSummary } from '@/constants/link-leak';
  78  import {
  79    DEFAULT_PROXY_CONFIG,
  80    PROXY_DESTINATIONS,
  81    PROXY_PREF_KEY,
  82    allInstancesFor,
  83    enabledInstanceCount,
  84    findDestinationForUrl,
  85    loadProxyConfig,
  86    newCustomDestId,
  87    normalizeProxyHost,
  88    rewriteThroughProxy,
  89    type CustomDest,
  90    type ProxyConfig,
  91    type ProxyDestination,
  92  } from '@/constants/proxies';
  93  import { formatRunicName } from '@/constants/runes';
  94  import {
  95    getBoolPref,
  96    getBrowserIcon,
  97    getInitialSharedText,
  98    getInitialViewUrl,
  99    getInstalledBrowsers,
 100    getStringPref,
 101    isDefaultBrowser,
 102    type LaunchMode,
 103    markSharedTextConsumed,
 104    markViewUrlConsumed,
 105    onIncomingUrl,
 106    onSharedTextChanged,
 107    launchPackage,
 108    openInBrowser,
 109    setBoolPref,
 110    setStringPref,
 111  } from '@/modules/default-browser';
 112  
 113  // Single source of truth for the app's display name. Read at bundle time
 114  // from app.json via expo-constants so renaming the app means editing
 115  // exactly one field (expo.name). Defaults are conservative fallbacks for
 116  // the unlikely case Constants isn't populated yet.
 117  // In-app display name. Hardcoded (not read from expo-constants) so JS
 118  // reloads pick up rebrands without a prebuild. App ID stays
 119  // `org.vikingware.webwarden` (legacy) so existing installs receive
 120  // updates — the display name "Warden" is decoupled.
 121  const APP_NAME: string = 'Warden';
 122  
 123  /**
 124   * Whether browser display names should be transliterated into
 125   * Elder Futhark runes. Provided by Home so every nested component
 126   * (BrowserPicker, FlowEditorModal, action sheets, encyclopedia, …)
 127   * can read the current toggle without prop-drilling.
 128   */
 129  const RunicNamesContext = React.createContext<boolean>(false);
 130  
 131  // Split the name into a "neutral head" + "accent tail" for the styled
 132  // wordmark in the header. The split is purely visual; if the name has at
 133  // least 4 characters the tail is the last 3 letters, otherwise the whole
 134  // name renders neutral.
 135  /**
 136   * Animated wordmark — letters of APP_NAME tinted from a looping
 137   * palette gradient. A single Animated.Value drives the cycle (0 → 1
 138   * every 3s); each letter offsets its phase by its position in the
 139   * word, so the colors visibly "flow" left-to-right through the
 140   * wordmark like a glow running across an inlay.
 141   *
 142   * useNativeDriver is forced to `false` because the native driver
 143   * doesn't animate `color` — fine here, the cost is negligible for
 144   * 7 letters and we're not animating layout.
 145   */
 146  /**
 147   * Quartered shield — outer ring, horizontal + vertical cross, two
 148   * opposite quadrants tinted, filled central boss. From shield #02
 149   * in the variations sketch.
 150   */
 151  function QuarteredShieldIcon({ size = 18, color = Palette.textMuted }: { size?: number; color?: string }) {
 152    // Force even thickness so all derived offsets stay on integer
 153    // pixels — Android fuzzes sub-pixel positions and the icon
 154    // visibly drifts off-axis without this.
 155    const thickness = 2;
 156    // Half-size = whole pixel (since size is even). Center coords clean.
 157    const half = size / 2;
 158    const bossPx = Math.max(2, Math.round(size * 0.12));
 159    // Lines start one thickness inside the rim so they don't fight
 160    // the borderRadius mask at the corners.
 161    const lineInset = thickness;
 162    const lineLen = size - thickness * 2;
 163    return (
 164      <View
 165        style={{
 166          width: size,
 167          height: size,
 168          borderRadius: half,
 169          overflow: 'hidden',
 170          borderWidth: thickness,
 171          borderColor: color,
 172          alignItems: 'center',
 173          justifyContent: 'center',
 174        }}>
 175        {/* Tinted quarters — top-left + bottom-right */}
 176        <View
 177          style={{
 178            position: 'absolute',
 179            left: 0, top: 0,
 180            width: half, height: half,
 181            backgroundColor: color,
 182            opacity: 0.18,
 183          }}
 184        />
 185        <View
 186          style={{
 187            position: 'absolute',
 188            left: half, top: half,
 189            width: half, height: half,
 190            backgroundColor: color,
 191            opacity: 0.18,
 192          }}
 193        />
 194        {/* Cross — clean integer positions */}
 195        <View
 196          style={{
 197            position: 'absolute',
 198            left: lineInset,
 199            top: half - thickness / 2,
 200            width: lineLen,
 201            height: thickness,
 202            backgroundColor: color,
 203          }}
 204        />
 205        <View
 206          style={{
 207            position: 'absolute',
 208            left: half - thickness / 2,
 209            top: lineInset,
 210            width: thickness,
 211            height: lineLen,
 212            backgroundColor: color,
 213          }}
 214        />
 215        <View
 216          style={{
 217            width: bossPx,
 218            height: bossPx,
 219            borderRadius: bossPx / 2,
 220            backgroundColor: color,
 221          }}
 222        />
 223      </View>
 224    );
 225  }
 226  
 227  /**
 228   * Tiny line drawn from (x1,y1) to (x2,y2) in a 100×100 coordinate
 229   * space, used by the shield-derived icon components below so they
 230   * stay readable at small sizes without pulling in react-native-svg.
 231   * Coords are relative to a -50..50 viewBox-style origin.
 232   */
 233  function IconLine({
 234    size,
 235    color,
 236    thickness,
 237    from,
 238    to,
 239  }: {
 240    size: number;
 241    color: string;
 242    thickness: number;
 243    from: [number, number];
 244    to: [number, number];
 245  }) {
 246    const [x1, y1] = from;
 247    const [x2, y2] = to;
 248    // Translate viewBox-style coords (-50..50, origin centered) into
 249    // pixel coords (0..size, origin top-left).
 250    const px1 = ((x1 + 50) * size) / 100;
 251    const py1 = ((y1 + 50) * size) / 100;
 252    const px2 = ((x2 + 50) * size) / 100;
 253    const py2 = ((y2 + 50) * size) / 100;
 254    const dx = px2 - px1;
 255    const dy = py2 - py1;
 256    const length = Math.sqrt(dx * dx + dy * dy);
 257    const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI;
 258    const cx = (px1 + px2) / 2;
 259    const cy = (py1 + py2) / 2;
 260    return (
 261      <View
 262        style={{
 263          position: 'absolute',
 264          left: cx - length / 2,
 265          top: cy - thickness / 2,
 266          width: length,
 267          height: thickness,
 268          backgroundColor: color,
 269          transform: [{ rotate: `${angleDeg}deg` }],
 270        }}
 271      />
 272    );
 273  }
 274  
 275  /**
 276   * Yggdrasil hint — vertical trunk + three pairs of upward branches
 277   * + two roots, all inside an outer ring. Derived from shield #19 in
 278   * the variations sketch.
 279   */
 280  function YggdrasilIcon({ size = 16, color = Palette.textMuted }: { size?: number; color?: string }) {
 281    const thickness = Math.max(1, Math.round(size / 14));
 282    const lines: [[number, number], [number, number]][] = [
 283      // Trunk
 284      [[0, 34], [0, -32]],
 285      // Top branches
 286      [[0, -32], [-14, -22]],
 287      [[0, -32], [14, -22]],
 288      // Mid branches
 289      [[0, -18], [-18, -6]],
 290      [[0, -18], [18, -6]],
 291      // Lower branches
 292      [[0, -4], [-22, 10]],
 293      [[0, -4], [22, 10]],
 294      // Roots
 295      [[0, 34], [-12, 40]],
 296      [[0, 34], [12, 40]],
 297    ];
 298    return (
 299      <View
 300        style={{
 301          width: size,
 302          height: size,
 303          borderRadius: size / 2,
 304          borderWidth: thickness,
 305          borderColor: color,
 306        }}>
 307        {lines.map((seg, i) => (
 308          <IconLine
 309            key={i}
 310            size={size}
 311            color={color}
 312            thickness={thickness}
 313            from={seg[0]}
 314            to={seg[1]}
 315          />
 316        ))}
 317      </View>
 318    );
 319  }
 320  
 321  /**
 322   * Studded-rim shield icon — outer ring with twelve rivets around
 323   * the perimeter and a filled central boss. From shield #18 in the
 324   * variations sketch.
 325   */
 326  function StuddedRimIcon({ size = 16, color = Palette.textMuted }: { size?: number; color?: string }) {
 327    const thickness = Math.max(1, Math.round(size / 14));
 328    // Stud positions on a circle of radius 40 (in -50..50 viewBox),
 329    // 12 stops evenly spaced (every 30°). Generated rather than
 330    // hand-listed so the math stays explicit.
 331    const studs = Array.from({ length: 12 }, (_, i) => {
 332      const angle = (i * 30 - 90) * (Math.PI / 180);
 333      return [Math.cos(angle) * 40, Math.sin(angle) * 40] as [number, number];
 334    });
 335    const studPx = Math.max(2, Math.round(size * 0.12));
 336    const bossPx = Math.max(3, Math.round(size * 0.25));
 337    return (
 338      <View
 339        style={{
 340          width: size,
 341          height: size,
 342          borderRadius: size / 2,
 343          borderWidth: thickness,
 344          borderColor: color,
 345          alignItems: 'center',
 346          justifyContent: 'center',
 347        }}>
 348        {studs.map(([sx, sy], i) => {
 349          const left = ((sx + 50) * size) / 100 - studPx / 2;
 350          const top = ((sy + 50) * size) / 100 - studPx / 2;
 351          return (
 352            <View
 353              key={i}
 354              style={{
 355                position: 'absolute',
 356                left,
 357                top,
 358                width: studPx,
 359                height: studPx,
 360                borderRadius: studPx / 2,
 361                backgroundColor: color,
 362              }}
 363            />
 364          );
 365        })}
 366        <View
 367          style={{
 368            width: bossPx,
 369            height: bossPx,
 370            borderRadius: bossPx / 2,
 371            backgroundColor: color,
 372          }}
 373        />
 374      </View>
 375    );
 376  }
 377  
 378  /**
 379   * Sun-disk / Sólarhjul icon — the viking 12-spoke wheel from the
 380   * shield variations sketch. Built from primitive Views so we don't
 381   * have to add react-native-svg: outer circle, central boss, six
 382   * lines crossing the center (each line = two opposite spokes for
 383   * a total of 12). Pure vector look, scales cleanly via `size`.
 384   */
 385  function SundiskIcon({ size = 16, color = Palette.textMuted }: { size?: number; color?: string }) {
 386    // 6 line angles spanning 0–150° give 12 spokes (each line is
 387    // diametric, covering two opposite spokes).
 388    const spokeAngles = [0, 30, 60, 90, 120, 150];
 389    const spokeThickness = Math.max(1, Math.round(size / 16));
 390    const bossSize = Math.max(2, Math.round(size / 4));
 391    return (
 392      <View
 393        style={{
 394          width: size,
 395          height: size,
 396          borderRadius: size / 2,
 397          borderWidth: spokeThickness,
 398          borderColor: color,
 399          alignItems: 'center',
 400          justifyContent: 'center',
 401        }}>
 402        {spokeAngles.map((angle) => (
 403          <View
 404            key={angle}
 405            style={{
 406              position: 'absolute',
 407              width: size - spokeThickness * 2,
 408              height: spokeThickness,
 409              backgroundColor: color,
 410              transform: [{ rotate: `${angle}deg` }],
 411            }}
 412          />
 413        ))}
 414        <View
 415          style={{
 416            width: bossSize,
 417            height: bossSize,
 418            borderRadius: bossSize / 2,
 419            backgroundColor: color,
 420          }}
 421        />
 422      </View>
 423    );
 424  }
 425  
 426  function WardenWordmark({ style }: { style?: any }) {
 427    const t = useRef(new Animated.Value(0)).current;
 428    useEffect(() => {
 429      // One full sweep across the wordmark (t: 0 → 2 over 4s), a 2s
 430      // rest with t held at 2 (every letter clamped past the palette
 431      // end → all deep), then a snap reset back to 0 and loop. Total
 432      // cycle 6s with a clear "pause between waves" beat.
 433      const sweep = Animated.timing(t, {
 434        toValue: 2,
 435        duration: 4000,
 436        easing: Easing.linear,
 437        useNativeDriver: false,
 438      });
 439      const rest = Animated.delay(2000);
 440      const reset = Animated.timing(t, {
 441        toValue: 0,
 442        duration: 0,
 443        useNativeDriver: false,
 444      });
 445      const loop = Animated.loop(Animated.sequence([sweep, rest, reset]));
 446      loop.start();
 447      return () => loop.stop();
 448    }, [t]);
 449  
 450    // Forest-only palette. Endpoints set the base (rest) color —
 451    // bumped from accentDeep to accent so during the 2s pause the
 452    // wordmark reads as a readable mossy green instead of dropping
 453    // to near-black. The sweep itself still climbs through bright
 454    // → highlight → bright back down.
 455    const palette = useMemo(() => [
 456      Palette.accent,
 457      Palette.accentBright,
 458      Palette.accentBright,
 459      Palette.highlight,
 460      Palette.accentBright,
 461      Palette.accentBright,
 462      Palette.accent,
 463    ], []);
 464    const inputRange = useMemo(
 465      () => palette.map((_, i) => i / (palette.length - 1)),
 466      [palette],
 467    );
 468  
 469    const letters = useMemo(() => APP_NAME.split(''), []);
 470  
 471    // Flat row of standalone Animated.Texts — nesting Animated.Text
 472    // inside a parent <Text> doesn't propagate color animations
 473    // reliably on Android, so we lay the letters out as a flex row
 474    // with baseline alignment. Each letter inherits the brandName
 475    // font from the `style` prop.
 476    return (
 477      <View style={{ flexDirection: 'row', alignItems: 'flex-end' }}>
 478        {letters.map((char, i) => {
 479          // Phase shift per letter. Subtraction (vs add) makes the
 480          // peak travel left → right as t increases. No modulo —
 481          // shifted just grows past 1 at the end of the sweep, and
 482          // the interpolate clamps it back to "deep" so the rest
 483          // period after t=2 reads as one calm forest tone.
 484          const phase = i / letters.length;
 485          const shifted = Animated.subtract(t, phase);
 486          const color = shifted.interpolate({
 487            inputRange,
 488            outputRange: palette,
 489            extrapolate: 'clamp',
 490          });
 491          // Glow halo dropped — read as a bright "white" pulse before,
 492          // and we want the wordmark to feel organic/smooth, not a
 493          // torch sweep. The color sweep alone carries the motion.
 494          return (
 495            <Animated.Text
 496              key={i}
 497              style={[style, { color }]}>
 498              {char}
 499            </Animated.Text>
 500          );
 501        })}
 502      </View>
 503    );
 504  }
 505  
 506  /**
 507   * Bundled launcher icons by browser id. Resolved at bundle time so we don't
 508   * need INTERNET permission, and they render the same whether the browser is
 509   * installed locally or not. Anything missing here falls back first to the
 510   * installed app's launcher icon (PackageManager → base64) and then to the
 511   * solid color swatch.
 512   */
 513  const ASSET_ICONS: Record<string, number> = {
 514    brave:             require('./assets/browser-icons/brave.png'),
 515    chrome:            require('./assets/browser-icons/chrome.png'),
 516    ddg:               require('./assets/browser-icons/ddg.png'),
 517    firefox:           require('./assets/browser-icons/firefox.png'),
 518    'firefox-beta':    require('./assets/browser-icons/firefox-beta.png'),
 519    felice:            require('./assets/browser-icons/felice.png'),
 520    'firefox-nightly': require('./assets/browser-icons/firefox-nightly.png'),
 521    focus:             require('./assets/browser-icons/focus.png'),
 522    ironfox:           require('./assets/browser-icons/ironfox.png'),
 523    tor:               require('./assets/browser-icons/tor.png'),
 524    vanadium:          require('./assets/browser-icons/vanadium.png'),
 525  };
 526  /**
 527   * Curated one-line endorsements rendered as a small pill under the
 528   * browser name in the picker. Keep this list short and opinionated —
 529   * a pill on every row defeats the point.
 530   */
 531  const BROWSER_RECOMMENDATION: Record<string, string> = {
 532    brave: '(best daily)',
 533  };
 534  
 535  /**
 536   * Which score icons (if any) to render in gold + filled for a given
 537   * browser, marking it as "best-in-class" on that axis. Matched to the
 538   * BROWSER_RECOMMENDATION labels: Vanadium tops Security, IronFox tops
 539   * Privacy, Brave is the all-rounder recommendation so both halves
 540   * glow. Kept as a small table so it's easy to retune.
 541   */
 542  const SCORE_ACCENT: Record<string, { security?: boolean; privacy?: boolean }> = {
 543    brave:    { security: true, privacy: true },
 544    vanadium: { security: true },
 545    tor:      { privacy: true },
 546    ironfox:  { privacy: true },
 547  };
 548  
 549  /**
 550   * Browsers that support third-party extensions / add-ons on Android.
 551   * Surfaced as a small puzzle icon on the picker row + a matching
 552   * encyclopedia bullet — lets users know they can layer uBlock Origin,
 553   * password managers, or other tooling on top.
 554   */
 555  const BROWSER_HAS_EXTENSIONS: Record<string, boolean> = {
 556    ironfox:          true,
 557    firefox:          true,
 558    'firefox-beta':   true,
 559    'firefox-nightly':true,
 560    helium:           true,
 561    // Tor Browser on Android (Fenix-based) supports a curated set of
 562    // extensions; the Tor Project advises against installing them
 563    // because they can de-anonymise — but the capability exists and
 564    // some users layer NoScript or password managers anyway.
 565    tor:              true,
 566    // Firefox Focus also runs on the Fenix runtime — modern builds
 567    // support extensions even though the headline pitch is
 568    // always-private + no-history.
 569    focus:            true,
 570  };
 571  
 572  // Warm gold used for the score accents and the autolaunch flash badge.
 573  const GOLD = '#f6c84c';
 574  
 575  /**
 576   * Availability caveats surfaced as a prominent info pill at the top of
 577   * the browser's encyclopedia modal. Use for browsers that can't be
 578   * installed in the usual way — e.g. only ship on a specific OS or
 579   * distribution. Keep the text short and parenthesised.
 580   */
 581  type AvailabilityNote = {
 582    /** Long phrasing for the encyclopedia info pill. */
 583    long: string;
 584    /** Compact phrasing for the picker row's not-installed line. */
 585    short: string;
 586  };
 587  const BROWSER_AVAILABILITY: Record<string, AvailabilityNote> = {
 588    vanadium: { long: 'Available only for GrapheneOS', short: 'only GrapheneOS' },
 589    helium:   { long: 'Available only for GrapheneOS', short: 'only GrapheneOS' },
 590  };
 591  
 592  function modeToLaunch(b: Browser, m: PrivacyMode): LaunchMode | null {
 593    // Always-private browsers (Tor, DDG): every visit is private regardless,
 594    // so we use the simplest intent shape and let the browser do its thing.
 595    if (b.alwaysPrivate) return 'normal';
 596    switch (m) {
 597      case 'normal':  return 'normal';
 598      case 'mini':    return 'cct-normal';
 599      case 'private':
 600        return b.privateMode === 'chromium-class' ? 'chromium-class'
 601             : b.privateMode === 'chromium-extra' ? 'chromium-extra'
 602             : b.privateMode === 'firefox'        ? 'firefox-private'
 603             : null;
 604      case 'max':     return 'cct-private';
 605    }
 606  }
 607  // Empty default — paste, share-intake, or a Browser-check preset
 608  // populates it. A bare hostname typed by the user is accepted; the
 609  // launch path normalises by prepending `https://` (see normalizeUrl).
 610  const DEFAULT_URL = '';
 611  
 612  // Window inside which the same incoming URL is treated as a duplicate
 613  // (one of the redundant native + JS intake pipes re-delivering). Past
 614  // this, the same URL can re-fire — e.g., the user back-gestures from
 615  // the browser and shares the same link again.
 616  const REINTAKE_WINDOW_MS = 1500;
 617  
 618  // SharedPreferences key for the strip-tracking toggle. Defaults to on.
 619  const PREF_STRIP_TRACKING = 'strip_tracking';
 620  const PREF_RUNIC_NAMES = 'runic_names';
 621  const PREF_SHOW_PASTE = 'show_paste';
 622  const PREF_SHOW_CLEAR = 'show_clear';
 623  
 624  // SharedPreferences key for the user's custom-preset URLs — a JSON
 625  // array of strings rendered inline at the bottom of the Presets modal.
 626  const PREF_CUSTOM_PRESETS = 'custom_presets';
 627  
 628  function loadCustomPresets(raw: string): string[] {
 629    try {
 630      const parsed = JSON.parse(raw);
 631      if (!Array.isArray(parsed)) return [];
 632      return parsed.filter((x): x is string => typeof x === 'string' && x.length > 0);
 633    } catch {
 634      return [];
 635    }
 636  }
 637  
 638  /**
 639   * Sentinel value stored in the URL field that means "this isn't a real URL —
 640   * each Flow should launch its browser's own internal config / flags page".
 641   * Selected from the Presets dropdown. Detected at launch time so the
 642   * browser's `configUrl` is substituted in just before dispatch.
 643   */
 644  const BROWSER_CONFIG_SENTINEL = 'warden:browser-config';
 645  
 646  type PresetIcon = keyof typeof Ionicons.glyphMap;
 647  type PresetItem = { label: string; url: string; hint: string; icon: PresetIcon };
 648  type PresetGroup = { id: string; label: string; items: PresetItem[] };
 649  
 650  /**
 651   * User-facing placeholder for the per-browser config URL. Sits in the
 652   * URL field as the literal string `@config`; at launch time we
 653   * substitute the chosen Flow's browser-specific `configUrl` (e.g.
 654   * `chrome://flags`, `about:config`) and route through the share path
 655   * because Android firewalls internal schemes from ACTION_VIEW.
 656   *
 657   * Kept as a single token so the UI stays generic ("Browser flags"
 658   * always maps to "whatever flags page this browser exposes") and we
 659   * can swap in a different per-browser URL later without changing the
 660   * preset table or any settings.
 661   */
 662  const CONFIG_PLACEHOLDER = '@browser.flags';
 663  
 664  /**
 665   * Grouped link presets surfaced in the Presets modal. Each group is
 666   * rendered as a small caps header followed by its items. Add a new
 667   * group object here — no rendering changes required.
 668   */
 669  const PRESET_GROUPS: PresetGroup[] = [
 670    {
 671      id: 'browser-config-and-checks',
 672      label: 'Browser Config & Checks',
 673      items: [
 674        {
 675          label: 'Browser flags',
 676          url: CONFIG_PLACEHOLDER,
 677          hint: 'Each Flow opens its own flags page (chrome://flags, about:config, …)',
 678          icon: 'flask-outline',
 679        },
 680        { label: 'Incognito check',     url: 'https://detectincognito.com',           hint: 'Tries to detect private mode',                   icon: 'eye-off-outline' },
 681        { label: 'IP & headers',        url: 'https://browserleaks.com/ip',           hint: 'Outbound IP + request headers',                  icon: 'globe-outline' },
 682        { label: 'JS & cookies',        url: 'https://browserleaks.com/javascript',   hint: 'document.cookie + runtime info',                 icon: 'cafe-outline' },
 683        { label: 'Client hints / UA',   url: 'https://browserleaks.com/client-hints', hint: 'User-Agent + Sec-CH-UA headers',                 icon: 'finger-print-outline' },
 684        { label: 'Canvas fingerprint',  url: 'https://browserleaks.com/canvas',       hint: 'Canvas hash — differs across isolated sessions', icon: 'brush-outline' },
 685        { label: 'WebGL fingerprint',   url: 'https://browserleaks.com/webgl',        hint: 'GPU/driver hash',                                icon: 'cube-outline' },
 686      ],
 687    },
 688  ];
 689  
 690  
 691  /**
 692   * Schemes recognised as "internal flag links" — they can't be opened
 693   * via Intent.ACTION_VIEW from a third-party app (the OS firewalls
 694   * them) so the launch path falls back to a Share intent. Conservative
 695   * allow-list so we never treat a `javascript:` or `file:` URL as a
 696   * flag link.
 697   */
 698  const FLAG_SCHEMES: ReadonlySet<string> = new Set([
 699    'chrome', 'chromium', 'brave', 'edge', 'vivaldi', 'opera', 'kiwi',
 700    'about',          // Firefox / Gecko
 701    'firefox',        // Some Fenix internal pages
 702  ]);
 703  
 704  function isFlagLinkUrl(s: string | null | undefined): boolean {
 705    if (!s) return false;
 706    const m = s.trim().match(/^([a-z]+):/i);
 707    if (!m) return false;
 708    return FLAG_SCHEMES.has(m[1].toLowerCase());
 709  }
 710  
 711  function extractUrlFromText(text: string | null | undefined): string | null {
 712    if (!text) return null;
 713    // Prefer an explicit http(s):// inside the text — handles shares like
 714    // "Cool article: https://example.com/foo" where there's prose around
 715    // the link.
 716    const match = text.match(/https?:\/\/[^\s]+/i);
 717    if (match) return match[0];
 718    // Fallback: the whole trimmed text might be a bare hostname (e.g.
 719    // "google.com" or "192.168.0.1/admin"). normalizeUrl validates and
 720    // prefixes https:// when it looks URL-shaped; returns null otherwise.
 721    return normalizeUrl(text);
 722  }
 723  
 724  function isHttpUrl(u: string | null | undefined): u is string {
 725    return !!u && /^https?:\/\//i.test(u);
 726  }
 727  
 728  /**
 729   * Normalise whatever the user typed in the URL field to a launchable
 730   * URL — or `null` if it isn't one. Accepts:
 731   *  - already-prefixed `http://…` / `https://…`
 732   *  - bare hostname like `google.com`, `sub.example.org/path`,
 733   *    `192.168.0.1`, `localhost:3000` — `https://` is prepended.
 734   * Trailing whitespace is trimmed. Anything else (single words without a
 735   * dot, free text, etc.) returns null so the launch path stays a no-op.
 736   */
 737  /**
 738   * Drop the trailing "/" added by `URL.toString()` when the pathname
 739   * is just "/" and there's no query or hash. HTTP-equivalent to keeping
 740   * it, but mirrors the structure of input that didn't have one — so
 741   * `example.com` doesn't display as `https://example.com/`.
 742   *
 743   * URLs with a non-root path keep their trailing slash, since on some
 744   * servers it's load-bearing (directory redirects, ETag salts, …).
 745   */
 746  function stripBareRootSlash(url: string): string {
 747    try {
 748      const u = new URL(url);
 749      if (u.pathname === '/' && !u.search && !u.hash) {
 750        return `${u.protocol}//${u.host}`;
 751      }
 752      return url;
 753    } catch {
 754      return url;
 755    }
 756  }
 757  
 758  function normalizeUrl(u: string | null | undefined): string | null {
 759    if (!u) return null;
 760    const s = u.trim();
 761    if (!s) return null;
 762    if (/^https?:\/\/.+/i.test(s)) return s;
 763    // Hostname-ish input — bare host, optional port, then any combination
 764    // of path / query / hash that a real URL can carry. We anchor the
 765    // host pattern and let `[/?#].*` swallow the rest in one go, so
 766    // strings like `example.com?x=1`, `example.com#sec`, and the older
 767    // `example.com/path?x=1#sec` all parse without the path being
 768    // required first. Three host shapes: dotted hostname, localhost,
 769    // bare IPv4.
 770    const tail = '(?::\\d+)?(?:[/?#].*)?';
 771    if (new RegExp(`^[a-z0-9][a-z0-9-]*(?:\\.[a-z0-9-]+)+${tail}$`, 'i').test(s)) {
 772      return `https://${s}`;
 773    }
 774    if (new RegExp(`^localhost${tail}$`, 'i').test(s)) return `https://${s}`;
 775    if (new RegExp(`^\\d{1,3}(?:\\.\\d{1,3}){3}${tail}$`).test(s)) return `https://${s}`;
 776    return null;
 777  }
 778  
 779  function Home() {
 780    const insets = useSafeAreaInsets();
 781  
 782    const [target, setTarget] = useState(DEFAULT_URL);
 783    // Ref mirror of target so handleOpenFlow always reads the latest URL
 784    // even when invoked from a stale closure (e.g. a Flow's onPress that
 785    // was captured before a rapid target change).
 786    const targetRef = useRef(target);
 787    useEffect(() => { targetRef.current = target; }, [target]);
 788    const [isExternal, setIsExternal] = useState(false);
 789    const [installed, setInstalled] = useState<Set<string>>(new Set());
 790    // Last successful installed set. Used as fallback when the native module
 791    // throws — we keep the previous value instead of showing every browser
 792    // as installed (which would let the user attempt a launch that fails).
 793    const installedRef = useRef(installed);
 794    useEffect(() => { installedRef.current = installed; }, [installed]);
 795    const [isDefault, setIsDefault] = useState<boolean>(false);
 796    const [errorMsg, setErrorMsg] = useState<string | null>(null);
 797    const [showPresets, setShowPresets] = useState(false);
 798    const [icons, setIcons] = useState<Record<string, string>>({});
 799  
 800    // User-defined launch Flows. Initialised synchronously; re-loaded once
 801    // after mount to dodge the cold-start race where the native module
 802    // briefly returns the default.
 803    const [flows, _setFlows] = useState<Flow[]>(() => loadFlows());
 804    const persistFlows = (next: Flow[]) => {
 805      _setFlows(next);
 806      saveFlows(next);
 807    };
 808    // The flow currently being edited (or freshly created). null = modal closed.
 809    const [editingFlow, setEditingFlow] = useState<Flow | null>(null);
 810    // Long-press action sheet target. null = sheet closed.
 811    const [actionFlow, setActionFlow] = useState<Flow | null>(null);
 812    // Long-press target for the right-side (launch) of a Flow row. Opens a
 813    // small "how do you want to open it?" picker — separate from the full
 814    // action sheet, which is long-press on the LEFT (edit) side.
 815    const [openOptionsFlow, setOpenOptionsFlow] = useState<Flow | null>(null);
 816    // First step of "New Flow" — pick a browser before the editor opens.
 817    const [newFlowPickerOpen, setNewFlowPickerOpen] = useState(false);
 818    // Active profile tab for filtering the flow list
 819    const [activeProfile, setActiveProfile] = useState<Profile>('privacy');
 820    const [aboutOpen, setAboutOpen] = useState(false);
 821    const [donateOpen, setDonateOpen] = useState(false);
 822    const [settingsOpen, setSettingsOpen] = useState(false);
 823    // Strip-tracking preference, persisted via the native module's
 824    // SharedPreferences shim. Defaults to true — matches Brave's
 825    // default and keeps the privacy-by-default posture.
 826    const [stripTracking, setStripTracking] = useState<boolean>(() =>
 827      getBoolPref(PREF_STRIP_TRACKING, true),
 828    );
 829    const updateStripTracking = (next: boolean) => {
 830      setStripTracking(next);
 831      setBoolPref(PREF_STRIP_TRACKING, next);
 832    };
 833    // Ref read by intake handlers so the latest pref value is used
 834    // without re-creating the listener closures on every toggle.
 835    const stripTrackingRef = useRef(stripTracking);
 836    useEffect(() => { stripTrackingRef.current = stripTracking; }, [stripTracking]);
 837  
 838    // Runic browser names — when on, every browser name renders as
 839    // Elder Futhark runes (Brave → ᛒᚱᚨᚹᛖ, Firefox → ᚠᛁᚱᛖᚠᛟᚲᛋ…).
 840    // Pure display flourish, off by default. Underlying data, error
 841    // strings, and accessibility labels stay in Latin so screen
 842    // readers and copy-paste continue to work.
 843    const [runicNames, setRunicNames] = useState<boolean>(() =>
 844      getBoolPref(PREF_RUNIC_NAMES, false),
 845    );
 846    const updateRunicNames = (next: boolean) => {
 847      setRunicNames(next);
 848      setBoolPref(PREF_RUNIC_NAMES, next);
 849    };
 850  
 851    // Optional Paste / Clear pills in the SOURCE row. Off by default —
 852    // most users don't need them, the keyboard + system paste menu
 853    // already covers it. Power users can opt in via Settings → UI.
 854    const [showPaste, setShowPaste] = useState<boolean>(() =>
 855      getBoolPref(PREF_SHOW_PASTE, false),
 856    );
 857    const updateShowPaste = (next: boolean) => {
 858      setShowPaste(next);
 859      setBoolPref(PREF_SHOW_PASTE, next);
 860    };
 861    const [showClear, setShowClear] = useState<boolean>(() =>
 862      getBoolPref(PREF_SHOW_CLEAR, false),
 863    );
 864    const updateShowClear = (next: boolean) => {
 865      setShowClear(next);
 866      setBoolPref(PREF_SHOW_CLEAR, next);
 867    };
 868  
 869    // Privacy-proxy redirect config (Nitter / Invidious / …). Persisted
 870    // as a single JSON blob in SharedPreferences so the schema can
 871    // evolve without adding new keys. Default = disabled.
 872    const [proxyConfig, setProxyConfigState] = useState<ProxyConfig>(() =>
 873      loadProxyConfig(getStringPref(PROXY_PREF_KEY, '')),
 874    );
 875  
 876    // User-defined preset URLs, surfaced at the bottom of the Presets
 877    // modal under a "Custom" header. Stored as a JSON array of strings.
 878    const [customPresets, setCustomPresetsState] = useState<string[]>(() =>
 879      loadCustomPresets(getStringPref(PREF_CUSTOM_PRESETS, '')),
 880    );
 881    const updateCustomPresets = (next: string[]) => {
 882      setCustomPresetsState(next);
 883      setStringPref(PREF_CUSTOM_PRESETS, JSON.stringify(next));
 884    };
 885    const proxyConfigRef = useRef(proxyConfig);
 886    useEffect(() => { proxyConfigRef.current = proxyConfig; }, [proxyConfig]);
 887    const updateProxyConfig = (next: ProxyConfig) => {
 888      setProxyConfigState(next);
 889      setStringPref(PROXY_PREF_KEY, JSON.stringify(next));
 890    };
 891  
 892    // Cycle index for the proxy rewrite. Walks forward through the
 893    // enabled-instance rotation deterministically — the cycle button
 894    // on the URL status row bumps this. When the matched destination
 895    // changes (or its randomise pref flips), an effect below resets
 896    // the index: 0 when randomise is off, a random seed when on.
 897    const [proxyCycleIndex, setProxyCycleIndex] = useState(0);
 898    // Quick-edit target — set by long-pressing the launch URL when the
 899    // link is proxied. Opens the per-destination settings shortcut so
 900    // the user can toggle instances without drilling through Settings.
 901    const [quickProxyDestId, setQuickProxyDestId] = useState<string | null>(null);
 902  
 903    // Single source of truth for "the URL Warden will actually launch":
 904    // normalize → unwrap → strip → rewrite, computed once per change so the
 905    // status preview and handleOpenFlow agree on the exact URL (and pick
 906    // the same proxy instance every time for a given cycle index).
 907    const launchPreview = useMemo(() => {
 908      const trimmed = target.trim();
 909      if (!trimmed) return null;
 910      if (
 911        target === BROWSER_CONFIG_SENTINEL ||
 912        target === CONFIG_PLACEHOLDER ||
 913        isFlagLinkUrl(target)
 914      ) return null;
 915      const normalised = normalizeUrl(target);
 916      if (!normalised) return null;
 917      const afterUnwrap = fullyUnwrapRedirect(normalised);
 918      const afterStrip = stripTracking
 919        ? stripTrackingParams(afterUnwrap.url)
 920        : { url: afterUnwrap.url, stripped: [] as string[] };
 921      const afterProxy = rewriteThroughProxy(afterStrip.url, proxyConfig, proxyCycleIndex);
 922      return {
 923        url: stripBareRootSlash(afterProxy.url),
 924        stripped: afterStrip.stripped,
 925        via: afterProxy.via,
 926        unwrappedHops: afterUnwrap.hops,
 927        matchedDest: findDestinationForUrl(normalised, proxyConfig),
 928      };
 929    }, [target, stripTracking, proxyConfig, proxyCycleIndex]);
 930    // Mirror for handleOpenFlow — closure-stable, always reads latest.
 931    const launchPreviewRef = useRef(launchPreview);
 932    useEffect(() => { launchPreviewRef.current = launchPreview; }, [launchPreview]);
 933  
 934    // Reset the cycle index whenever the matched destination identity
 935    // (or its randomise pref) changes. With randomise on we seed a
 936    // random starting index so first-view is a fresh pick; otherwise
 937    // we anchor to 0 so the user always sees the first enabled
 938    // instance until they tap cycle.
 939    const matchedDestId = launchPreview?.matchedDest?.id ?? null;
 940    useEffect(() => {
 941      if (!matchedDestId) {
 942        setProxyCycleIndex(0);
 943        return;
 944      }
 945      const dest = launchPreview?.matchedDest;
 946      if (!dest || dest.id !== matchedDestId) return;
 947      // Auto-randomise the starting pick whenever multiple instances
 948      // are enabled. With a single enabled instance there's nothing to
 949      // randomise — anchor to 0. No user-facing toggle: the rule is
 950      // simply "multiple enabled ⇒ randomised first pick, then the
 951      // cycle button steps forward."
 952      const n = enabledInstanceCount(dest, proxyConfig);
 953      setProxyCycleIndex(n > 1 ? Math.floor(Math.random() * n) : 0);
 954      // Re-seed on destination identity change only — toggling an
 955      // instance off shouldn't yank the user to a different proxy.
 956      // eslint-disable-next-line react-hooks/exhaustive-deps
 957    }, [matchedDestId]);
 958  
 959    // Post-mount re-read of flows. Catches the cold-start race where the
 960    // Expo module's reactContext is briefly null and getStringPref returns
 961    // the default.
 962    useEffect(() => {
 963      try {
 964        const fresh = loadFlows();
 965        if (fresh.length !== flows.length) _setFlows(fresh);
 966      } catch {}
 967      // eslint-disable-next-line react-hooks/exhaustive-deps
 968    }, []);
 969  
 970    // Last-seen incoming URL + timestamp. The dedup window collapses
 971    // the redundant fan-out (expo-linking event + native event + the
 972    // foreground poll all firing the same URL within ms of each other)
 973    // without permanently blocking the same URL from being shared again
 974    // later in the session. After REINTAKE_WINDOW_MS the same URL is
 975    // treated as a fresh intake.
 976    const lastConsumed = useRef<{ url: string; ts: number } | null>(null);
 977    // Refs the event listeners read instead of closing over render-scoped
 978    // state — keeps share-intake / auto-fire working for Flows created or
 979    // edited later in the same session.
 980    const flowsRef = useRef(flows);
 981    const editingFlowRef = useRef(editingFlow);
 982    useEffect(() => { flowsRef.current = flows; }, [flows]);
 983    useEffect(() => { editingFlowRef.current = editingFlow; }, [editingFlow]);
 984  
 985    // When any blocking modal is open we stash the incoming URL instead
 986    // of slamming it into the field underneath. Drained by the effect
 987    // below once every modal closes.
 988    const anyModalOpen =
 989      editingFlow !== null ||
 990      actionFlow !== null ||
 991      openOptionsFlow !== null ||
 992      newFlowPickerOpen ||
 993      aboutOpen ||
 994      donateOpen ||
 995      settingsOpen ||
 996      showPresets;
 997    const anyModalOpenRef = useRef(anyModalOpen);
 998    const pendingIntakeRef = useRef<string | null>(null);
 999    useEffect(() => { anyModalOpenRef.current = anyModalOpen; }, [anyModalOpen]);
1000    // Whenever any blocking modal opens, drop URL-field focus so the
1001    // keyboard isn't trapped under the modal sheet. Cheap to call
1002    // every transition; no-op when nothing's focused.
1003    useEffect(() => {
1004      if (anyModalOpen) Keyboard.dismiss();
1005    }, [anyModalOpen]);
1006  
1007    const refreshDefaultStatus = () => {
1008      try { setIsDefault(isDefaultBrowser()); } catch { setIsDefault(false); }
1009    };
1010    const refreshInstalled = () => {
1011      try { setInstalled(new Set(getInstalledBrowsers())); } catch {
1012        setInstalled(installedRef.current);
1013      }
1014    };
1015    const consumeIncoming = (incoming: string | null) => {
1016      if (!isHttpUrl(incoming)) return;
1017      // Preserve the original URL as received — tracking-param strip is
1018      // applied only to the *final* launch URL (computed in launchPreview
1019      // and used by handleOpenFlow), so the field always shows what the
1020      // sender actually sent. Just drop the bare-root "/" that
1021      // URL.toString() might have added.
1022      const cleaned = stripBareRootSlash(incoming);
1023      // Modal blocker: if the user is mid-edit (or in any blocking
1024      // modal), don't slam the field underneath them. Stash and let the
1025      // close-effect below drain it once the modal goes away. Latest
1026      // wins — if two URLs arrive during the same modal session, the
1027      // second replaces the first.
1028      if (anyModalOpenRef.current) {
1029        pendingIntakeRef.current = cleaned;
1030        return;
1031      }
1032      const now = Date.now();
1033      const last = lastConsumed.current;
1034      // Same URL inside the dedup window? Probably one of the redundant
1035      // pipes (expo-linking, native event, foreground poll) re-delivering.
1036      // Outside the window, treat it as a fresh intake — handy when the
1037      // user comes back to Warden and re-shares the same link.
1038      if (last && last.url === cleaned && now - last.ts < REINTAKE_WINDOW_MS) {
1039        return;
1040      }
1041      lastConsumed.current = { url: cleaned, ts: now };
1042      setTarget(cleaned);
1043      setIsExternal(true);
1044      setErrorMsg(null);
1045      // Incoming link = the user wants to pick a Flow, not edit the
1046      // URL. Make sure no field gets focus / no keyboard pops on
1047      // activity-resume from a VIEW intent. Safe no-op if no input
1048      // was focused.
1049      Keyboard.dismiss();
1050      // Zero-tap path: exactly one Flow opted into auto-fire? Fire it now.
1051      // Multiple matches → fall back to showing the menu, since "which one"
1052      // is ambiguous. We read from refs to stay in sync with Flows created
1053      // or edited later in the same session.
1054      const armed = flowsRef.current.filter((f) => f.autoFire);
1055      if (armed.length === 1) {
1056        // Pass the URL explicitly because setTarget(...) hasn't committed
1057        // to the rendered state by the time we want to launch.
1058        handleOpenFlow(armed[0], cleaned);
1059      }
1060    };
1061  
1062    // Drain any stashed intake once all modals close. Runs whenever
1063    // `anyModalOpen` transitions; we only fire the consume on the
1064    // open → closed edge.
1065    useEffect(() => {
1066      if (anyModalOpen) return;
1067      const stash = pendingIntakeRef.current;
1068      if (!stash) return;
1069      pendingIntakeRef.current = null;
1070      consumeIncoming(stash);
1071      // eslint-disable-next-line react-hooks/exhaustive-deps
1072    }, [anyModalOpen]);
1073  
1074    useEffect(() => {
1075      refreshDefaultStatus();
1076      refreshInstalled();
1077      Linking.getInitialURL().then(consumeIncoming);
1078      const urlSub = Linking.addEventListener('url', ({ url }) => {
1079        consumeIncoming(url);
1080        markViewUrlConsumed();
1081      });
1082      // Cold-start drain: pick up whatever VIEW/SEND intent launched the
1083      // app. extractUrlFromText handles both an explicit https?:// in the
1084      // text and a bare hostname; getInitialViewUrl handles the
1085      // link-click path. Both feed the same dedup. We no longer poll on
1086      // AppState 'active' — the native onIncomingUrl / onSharedTextChanged
1087      // events fire reliably for warm starts and the cold-start drain
1088      // here covers the rest.
1089      consumeIncoming(getInitialViewUrl());
1090      consumeIncoming(extractUrlFromText(getInitialSharedText()));
1091      // Native fires these as soon as the matching intent lands in our
1092      // running task. Avoids waiting for an AppState transition that may
1093      // never come (the activity sometimes never loses focus while the OS
1094      // routes the new intent).
1095      const shareSub = onSharedTextChanged((text) => {
1096        consumeIncoming(extractUrlFromText(text));
1097        markSharedTextConsumed();
1098      });
1099      const viewSub = onIncomingUrl((url) => {
1100        consumeIncoming(url);
1101        markViewUrlConsumed();
1102      });
1103      // Native intent drain. Called on resume and again with short
1104      // delays — Android sometimes dispatches the new VIEW intent to
1105      // onNewIntent AFTER AppState 'active' fires, so the immediate
1106      // read can return the stale (previous) activity.intent and miss
1107      // the link. Each retry is idempotent via the native consumed
1108      // flags + JS dedup window; worst case is a couple of no-op pings.
1109      const drainNativeIntents = () => {
1110        consumeIncoming(getInitialViewUrl());
1111        consumeIncoming(extractUrlFromText(getInitialSharedText()));
1112      };
1113      const appStateSub = AppState.addEventListener('change', (state) => {
1114        if (state === 'active') {
1115          refreshDefaultStatus();
1116          refreshInstalled();
1117          // Three-shot drain. 0ms catches the common case. 250ms covers
1118          // same-tick races (intent arrives just after 'active'). 1000ms
1119          // is the long backstop for slower cold-resume paths.
1120          drainNativeIntents();
1121          setTimeout(drainNativeIntents, 250);
1122          setTimeout(drainNativeIntents, 1000);
1123        }
1124      });
1125      return () => {
1126        urlSub.remove();
1127        shareSub.remove();
1128        viewSub.remove();
1129        appStateSub.remove();
1130      };
1131      // eslint-disable-next-line react-hooks/exhaustive-deps
1132    }, []);
1133  
1134    // Pull launcher icons for every known browser, not just the visible list.
1135    // Hidden / deprecated rows benefit from showing the real icon too. For
1136    // packages that aren't installed, getBrowserIcon returns null and the row
1137    // falls back to the colored swatch. We cache each successful fetch and
1138    // skip browsers we've already fetched.
1139    useEffect(() => {
1140      const next: Record<string, string> = { ...icons };
1141      let changed = false;
1142      BROWSERS.forEach((b) => {
1143        if (next[b.pkg]) return; // already cached
1144        const data = getBrowserIcon(b.pkg);
1145        if (data) {
1146          next[b.pkg] = data;
1147          changed = true;
1148        }
1149      });
1150      if (changed) setIcons(next);
1151      // eslint-disable-next-line react-hooks/exhaustive-deps
1152    }, [installed]);
1153  
1154    const handleShare = async () => {
1155      if (!target) return;
1156      // Sharing out = the user's done editing. Drop focus so the
1157      // keyboard isn't covering the share sheet.
1158      Keyboard.dismiss();
1159      try { await Share.share({ message: target, url: target }); } catch {}
1160    };
1161  
1162    const handlePaste = async () => {
1163      try {
1164        const text = await Clipboard.getStringAsync();
1165        if (text) {
1166          const url = extractUrlFromText(text) ?? text.trim();
1167          setTarget(url);
1168          setErrorMsg(null);
1169        }
1170      } catch {}
1171    };
1172  
1173    const handleOpenFlow = async (f: Flow, urlOverride?: string) => {
1174      // User picked a Flow → done editing the URL field. Drop focus
1175      // so the keyboard isn't covering anything by the time the
1176      // browser launches (or the action sheet appears).
1177      Keyboard.dismiss();
1178      const raw = urlOverride ?? targetRef.current;
1179      const b = BROWSERS.find((x) => x.pkg === f.browserPkg);
1180      if (!b) {
1181        setErrorMsg(`Browser ${f.browserPkg} is not installed.`);
1182        return;
1183      }
1184  
1185      // @config placeholder: the user picked the generic "Browser flags"
1186      // preset, so we substitute this browser's specific configUrl and
1187      // route through share. The OS firewalls internal schemes from
1188      // ACTION_VIEW; Share is the only path that lets the user actually
1189      // open the page (typically via Copy → paste in address bar).
1190      if (raw === CONFIG_PLACEHOLDER) {
1191        if (!b.configUrl) {
1192          setErrorMsg(`${b.name} doesn't expose a flags page.`);
1193          return;
1194        }
1195        setErrorMsg(null);
1196        try {
1197          await Share.share({ message: b.configUrl, url: b.configUrl });
1198        } catch {
1199          // User dismissed the share sheet — no error to surface.
1200        }
1201        if (isExternal || urlOverride) BackHandler.exitApp();
1202        return;
1203      }
1204  
1205      // Manually-entered flag URL (e.g. user typed `about:config`
1206      // directly into the field). Same share treatment, but the URL is
1207      // taken as-is rather than per-browser.
1208      if (isFlagLinkUrl(raw)) {
1209        setErrorMsg(null);
1210        try {
1211          await Share.share({ message: raw, url: raw });
1212        } catch {}
1213        if (isExternal || urlOverride) BackHandler.exitApp();
1214        return;
1215      }
1216  
1217      // Legacy "Browser config" sentinel — preserved for any saved
1218      // state from older builds. New flows use CONFIG_PLACEHOLDER above.
1219      if (raw === BROWSER_CONFIG_SENTINEL) {
1220        if (!b.configUrl) return;
1221        setErrorMsg(null);
1222        await Clipboard.setStringAsync(b.configUrl);
1223        const launched = launchPackage(f.browserPkg);
1224        if (!launched) {
1225          setErrorMsg(`Could not launch ${b.name}. The browser may have been uninstalled.`);
1226          return;
1227        }
1228        ToastAndroid.show(
1229          `${b.configUrl} copied — paste in the address bar`,
1230          ToastAndroid.LONG,
1231        );
1232        if (isExternal || urlOverride) BackHandler.exitApp();
1233        return;
1234      }
1235  
1236      // Prefer the memoised launch preview — it's the exact URL shown in
1237      // the status row and locks in the proxy random pick the user saw.
1238      // Fall back to recomputing for the auto-fire case where the user
1239      // never saw the preview render against this URL yet.
1240      let url: string | null = null;
1241      if (!urlOverride && launchPreviewRef.current?.url) {
1242        url = launchPreviewRef.current.url;
1243      } else {
1244        const normalised = normalizeUrl(raw);
1245        if (normalised) {
1246          const unwrapped = fullyUnwrapRedirect(normalised);
1247          const stripped = stripTrackingRef.current
1248            ? stripTrackingParams(unwrapped.url).url
1249            : unwrapped.url;
1250          url = rewriteThroughProxy(stripped, proxyConfigRef.current).url;
1251        }
1252      }
1253      if (!url) return;
1254      setErrorMsg(null);
1255  
1256      // Link-leak check: warn if the URL carries credentials, emails,
1257      // JWTs, token parameters, or precise coordinates before handing
1258      // it to the chosen browser.
1259      const leaks = checkLinkLeaks(url);
1260      if (leaks.length > 0) {
1261        const summary = leakSummary(leaks);
1262        const proceed = await new Promise<boolean>((resolve) => {
1263          Alert.alert(
1264            'Sensitive data in link',
1265            summary,
1266            [
1267              { text: 'Cancel', style: 'cancel', onPress: () => resolve(false) },
1268              { text: 'Open anyway', onPress: () => resolve(true) },
1269            ],
1270            { cancelable: true },
1271          );
1272        });
1273        if (!proceed) return;
1274      }
1275  
1276      const launch = modeToLaunch(b, f.mode);
1277      const ok = openInBrowser(url, f.browserPkg, launch, f.extras);
1278      if (!ok) {
1279        setErrorMsg(`Could not launch ${b.name}. The browser may have been uninstalled.`);
1280        return;
1281      }
1282      // External path = arrived via share / VIEW intent. After firing the
1283      // browser we exit so back-from-browser returns to the originating app.
1284      if (isExternal || urlOverride) BackHandler.exitApp();
1285    };
1286  
1287    const upsertFlow = (f: Flow) => {
1288      // Soft mutual-exclusion: only one Flow may have autoFire=true at any
1289      // time. When the user toggles it on for this Flow, clear it from
1290      // every other one so the share-intake path stays unambiguous.
1291      const cleared = f.autoFire
1292        ? flows.map((x) => (x.id === f.id ? x : { ...x, autoFire: false }))
1293        : flows;
1294      const i = cleared.findIndex((x) => x.id === f.id);
1295      if (i < 0) persistFlows([...cleared, f]);
1296      else {
1297        const next = [...cleared];
1298        next[i] = f;
1299        persistFlows(next);
1300      }
1301    };
1302  
1303    const deleteFlow = (id: string) => {
1304      persistFlows(flows.filter((f) => f.id !== id));
1305    };
1306  
1307    /**
1308     * Ask before deleting — both the editor's Delete button and the
1309     * action-sheet's Delete Flow entry are one tap from "gone". A native
1310     * Alert keeps the confirmation lightweight without needing a third
1311     * stacked modal.
1312     */
1313    const confirmDeleteFlow = (id: string, after: () => void) => {
1314      const f = flows.find((x) => x.id === id);
1315      const b = f ? BROWSERS.find((x) => x.pkg === f.browserPkg) : null;
1316      const tag = (f?.title ?? '').trim();
1317      const label = tag
1318        ? `${b?.name ?? f?.browserPkg ?? 'Flow'} · ${tag}`
1319        : (b?.name ?? f?.browserPkg ?? 'this Flow');
1320      Alert.alert(
1321        `Delete "${label}"?`,
1322        'This cannot be undone.',
1323        [
1324          { text: 'Cancel', style: 'cancel' },
1325          {
1326            text: 'Delete',
1327            style: 'destructive',
1328            onPress: () => { deleteFlow(id); after(); },
1329          },
1330        ],
1331      );
1332    };
1333  
1334    const moveFlow = (id: string, delta: -1 | 1) => {
1335      const i = flows.findIndex((f) => f.id === id);
1336      if (i < 0) return;
1337      const j = i + delta;
1338      if (j < 0 || j >= flows.length) return;
1339      const next = [...flows];
1340      [next[i], next[j]] = [next[j], next[i]];
1341      persistFlows(next);
1342    };
1343  
1344  
1345    /** Toggle autoFire on an existing Flow without re-opening the editor. */
1346    const toggleAutoFire = (id: string) => {
1347      const current = flows.find((f) => f.id === id);
1348      if (!current) return;
1349      upsertFlow({ ...current, autoFire: !current.autoFire });
1350    };
1351  
1352    const startNewFlow = (browserPkg: string, profile: Profile = activeProfile): Flow => {
1353      const b = BROWSERS.find((x) => x.pkg === browserPkg);
1354      const d = defaultsForProfile(profile, browserPkg, b);
1355      return {
1356        id: newFlowId(),
1357        title: '',
1358        subtitle: '',
1359        browserPkg,
1360        mode: d.mode,
1361        extras: d.extras,
1362        profile,
1363        autoFire: false,
1364      };
1365    };
1366  
1367    const openDefaultAppsSettings = async () => {
1368      try {
1369        await IntentLauncher.startActivityAsync('android.settings.MANAGE_DEFAULT_APPS_SETTINGS');
1370      } catch {
1371        try {
1372          await IntentLauncher.startActivityAsync(
1373            'android.settings.APPLICATION_DETAILS_SETTINGS',
1374            { data: 'package:org.vikingware.webwarden' },
1375          );
1376        } catch {
1377          Alert.alert(
1378            'Could not open settings',
1379            `Open Android Settings → Apps → Default apps → Browser app, and select ${APP_NAME}.`,
1380          );
1381        }
1382      }
1383    };
1384  
1385    return (
1386      <RunicNamesContext.Provider value={runicNames}>
1387      <ScrollView
1388        style={styles.root}
1389        contentContainerStyle={[styles.content, { paddingTop: insets.top + 14 }]}
1390        keyboardShouldPersistTaps="handled"
1391        >
1392        {/* Tap-to-dismiss handled by the ScrollView's
1393            keyboardShouldPersistTaps="handled" (above) and explicit
1394            Keyboard.dismiss() calls in handleOpenFlow / handleShare /
1395            consumeIncoming / the anyModalOpen effect. An outer
1396            Pressable wrap is unnecessary and was eating taps on the
1397            URL TextInput, immediately blurring the field. */}
1398        <View style={styles.topBar}>
1399          {/* One-row navbar: brand (logo + wordmark) on the left, the
1400              default-browser status / CTA + settings cluster on the
1401              right. Version dropped from this row — surfaced in the
1402              About modal instead, which is reachable by tapping the
1403              brand block. */}
1404          {/* Brand block is passive identity — logo + wordmark are not
1405              tappable. "About" and "Donate" beneath are the actual
1406              text-buttons, each its own Pressable. */}
1407          <View style={styles.brandRow}>
1408            <Image
1409              source={require('./assets/images/warden-mark.png')}
1410              style={styles.brandMark}
1411              accessibilityIgnoresInvertColors
1412            />
1413            <View style={styles.brandText}>
1414              {/* Animated WardenWordmark disabled for now — swap back
1415                  to <WardenWordmark style={styles.brandName} /> to
1416                  re-enable the moving forest gradient. */}
1417              <Text style={[styles.brandName, { color: Palette.highlight }]}>
1418                {APP_NAME}
1419              </Text>
1420              <Text style={styles.brandSlogan}>The Browser-Link Guardian.</Text>
1421              <View style={styles.versionRow}>
1422                <Text style={styles.brandVersionInline}>v{BUILD_VERSION}</Text>
1423                <Text style={styles.versionDot}>·</Text>
1424                <Pressable
1425                  onPress={() => setAboutOpen(true)}
1426                  hitSlop={6}
1427                  accessibilityLabel="About"
1428                  accessibilityRole="button">
1429                  <Text style={styles.versionAboutLink}>About</Text>
1430                </Pressable>
1431                <Text style={styles.versionDot}>·</Text>
1432                <Pressable
1433                  onPress={() => setDonateOpen(true)}
1434                  hitSlop={6}
1435                  accessibilityLabel="Donate"
1436                  accessibilityRole="button">
1437                  <Text style={styles.versionAboutLink}>Donate</Text>
1438                </Pressable>
1439              </View>
1440            </View>
1441          </View>
1442        </View>
1443  
1444        {/* Shelf under the hairline: Settings left, default-browser
1445            status right. */}
1446        <View style={styles.metaRow}>
1447          <Pressable
1448            onPress={() => setSettingsOpen(true)}
1449            hitSlop={8}
1450            accessibilityLabel="Settings"
1451            style={({ pressed }) => [
1452              styles.settingsIconBtn,
1453              pressed && styles.statusPressed,
1454            ]}>
1455            <Ionicons name="settings-outline" size={16} color={Palette.textMuted} />
1456            <Text style={styles.settingsIconLabel}>Settings</Text>
1457          </Pressable>
1458          {/* When already default, drop the confirmation pill — the
1459              row reads as Settings-only, no noise. When NOT default,
1460              surface a glowing CTA so the user knows the app isn't
1461              wired up to actually route links yet. */}
1462          {!isDefault ? (
1463            <Pressable
1464              onPress={openDefaultAppsSettings}
1465              style={({ pressed }) => [
1466                styles.setDefaultCtaGlow,
1467                pressed && styles.statusPressed,
1468              ]}>
1469              <Ionicons name="shield-outline" size={16} color={Palette.textMuted} />
1470              <Text style={styles.setDefaultCtaGlowText}>Make Default</Text>
1471            </Pressable>
1472          ) : null}
1473        </View>
1474  
1475  
1476        <View style={styles.zoneStack}>
1477        <View style={styles.topGroup}>
1478        {/* Corner brackets visually "scope" the LINK section without
1479            drawing a full border box — keeps the quiet-rails feel
1480            while signalling that everything inside (tools row, URL
1481            field, status panel) belongs to one input region. */}
1482        {/* Scope corners removed entirely — the URL section reads as
1483            open chrome, no viewfinder framing. */}
1484        {/* Tools row — LINK zone marker + Presets + Paste + Clear on
1485            the left, Share floating on the right. Inline, no card
1486            chrome. The order reads input-flow first (presets / paste),
1487            then state cleanup (clear), with Share as the outbound
1488            action separated to the right edge. */}
1489        <View style={styles.urlToolsPanel}>
1490          {/* Left cluster: LINK label + Presets. */}
1491          <View style={styles.urlHeaderLeft}>
1492            <View style={styles.linkLabelBox}>
1493              <Text style={styles.zoneLabel}>SOURCE</Text>
1494            </View>
1495            <Pressable
1496              onPress={() => setShowPresets(!showPresets)}
1497              hitSlop={6}
1498              style={({ pressed }) => [styles.testsBtn, pressed && styles.testsBtnPressed]}>
1499              <Text style={styles.testsBtnText}>Presets</Text>
1500              <Ionicons
1501                name="chevron-forward"
1502                size={12}
1503                color={Palette.accentBright}
1504              />
1505            </Pressable>
1506          </View>
1507          {/* Right cluster: optional Paste / Clear (off by default
1508              in Settings → UI) and Share, spread evenly across the
1509              remaining width with Share anchored at the right. */}
1510          <View style={styles.urlHeaderRightGroup}>
1511            {showPaste ? (
1512              <Pressable
1513                onPress={handlePaste}
1514                hitSlop={10}
1515                style={({ pressed }) => [styles.urlActionPill, pressed && styles.iconBtnPressed]}
1516                accessibilityLabel="Paste from clipboard">
1517                <Ionicons name="clipboard-outline" size={11} color={Palette.accentBright} />
1518                <Text style={styles.urlActionPillText}>Paste</Text>
1519              </Pressable>
1520            ) : null}
1521            {showClear ? (
1522              <Pressable
1523                onPress={() => {
1524                  setTarget('');
1525                  setErrorMsg(null);
1526                  lastConsumed.current = null;
1527                }}
1528                hitSlop={10}
1529                style={({ pressed }) => [styles.urlActionPill, pressed && styles.iconBtnPressed]}
1530                accessibilityLabel="Clear URL">
1531                <MaterialCommunityIcons name="broom" size={11} color={Palette.accentBright} />
1532                <Text style={styles.urlActionPillText}>Clear</Text>
1533              </Pressable>
1534            ) : null}
1535            <Pressable
1536              onPress={handleShare}
1537              hitSlop={10}
1538              style={({ pressed }) => [styles.urlActionPill, pressed && styles.iconBtnPressed]}
1539              accessibilityLabel="Share this link">
1540              <Ionicons name="share-social-outline" size={11} color={Palette.accentBright} />
1541              <Text style={styles.urlActionPillText}>Source</Text>
1542            </Pressable>
1543          </View>
1544        </View>
1545  
1546        {/* Link panel — the actual URL field (or the browser-config banner
1547            when that preset is active). Stands on its own as a separate
1548            input surface. */}
1549        <View style={styles.urlFieldPanel}>
1550          {target === BROWSER_CONFIG_SENTINEL ? (
1551            <View style={styles.urlConfigBanner}>
1552              <Ionicons name="construct-outline" size={14} color={Palette.accentBright} />
1553              <Text style={styles.urlConfigBannerText}>
1554                Copies the config URL · opens the browser to paste it
1555              </Text>
1556            </View>
1557          ) : (
1558            <TextInput
1559              value={target}
1560              onChangeText={setTarget}
1561              style={styles.urlInput}
1562              multiline
1563              autoCapitalize="none"
1564              autoCorrect={false}
1565              keyboardType="url"
1566              selectionColor={Palette.highlight}
1567              placeholderTextColor={Palette.textMuted}
1568              placeholder=""
1569            />
1570          )}
1571        </View>
1572  
1573        <PresetsModal
1574          visible={showPresets}
1575          customPresets={customPresets}
1576          onSetCustomPresets={updateCustomPresets}
1577          onClose={() => setShowPresets(false)}
1578          onPickUrl={(url) => {
1579            setTarget(url);
1580            setShowPresets(false);
1581            setErrorMsg(null);
1582          }}
1583        />
1584  
1585        {/* URL status panel. Always rendered (except when the field is
1586            in browser-config mode, where it shows a banner instead of
1587            a URL) so the layout doesn't jump as the URL becomes valid
1588            / invalid / empty. The panel is purely informational about
1589            the URL — no Flows required.
1590            When the URL is valid we show the actual launchable URL
1591            (post-normalize + post-strip) so the user can see exactly
1592            what Warden will send to the browser. */}
1593        {target !== BROWSER_CONFIG_SENTINEL ? (() => {
1594          const trimmed = target.trim();
1595  
1596          // Resolve the four pieces every state needs to populate.
1597          // iconColor + labelColor switch to highlight only when there's
1598          // an actual ready/transform state to celebrate.
1599          let iconName: keyof typeof Ionicons.glyphMap = 'arrow-up-outline';
1600          let iconColor: string = Palette.textMuted;
1601          let labelColor: string = Palette.textMuted;
1602          let status = 'Paste or type a URL';
1603          // Detail content — a string or rich React node, rendered
1604          // inside the always-present link icon row below. Defaults to
1605          // the "awaiting valid url" placeholder so the link icon
1606          // always has something to anchor; valid / proxied / flag
1607          // states overwrite below.
1608          let detailText: React.ReactNode = (
1609            <Text style={styles.urlHintPlaceholder}>Awaiting URL</Text>
1610          );
1611          let detailLongPress: (() => void) | undefined;
1612          let detailPress: (() => void) | undefined;
1613          let canCycle = false;
1614  
1615          if (trimmed === CONFIG_PLACEHOLDER) {
1616            iconName = 'share-outline';
1617            iconColor = Palette.highlight;
1618            labelColor = Palette.highlight;
1619            status = 'Browser flags';
1620            detailText = <Text style={styles.urlHintLaunch}>{CONFIG_PLACEHOLDER}</Text>;
1621          } else if (isFlagLinkUrl(trimmed)) {
1622            iconName = 'share-outline';
1623            iconColor = Palette.highlight;
1624            labelColor = Palette.highlight;
1625            status = 'Flag URL · shared on Open';
1626            detailText = <Text style={styles.urlHintLaunch}>{trimmed}</Text>;
1627          } else if (launchPreview) {
1628            iconName = launchPreview.via ? 'shuffle-outline' : 'checkmark-circle-outline';
1629            iconColor = Palette.highlight;
1630            labelColor = Palette.highlight;
1631            const strippedCount = launchPreview.stripped.length;
1632            // Compose multiple status pieces — proxied + stripped can
1633            // both be true; they're joined with the same · separator
1634            // used elsewhere in the app.
1635            const pieces: string[] = [];
1636            if (launchPreview.via) pieces.push('Proxied');
1637            if (strippedCount > 0) {
1638              pieces.push(
1639                `Removed ${strippedCount} tracking param${strippedCount === 1 ? '' : 's'}`,
1640              );
1641            }
1642            if (pieces.length === 0) pieces.push('Direct');
1643            status = pieces.join(' · ');
1644            canCycle = !!(
1645              launchPreview.via &&
1646              launchPreview.matchedDest &&
1647              enabledInstanceCount(launchPreview.matchedDest, proxyConfig) > 1
1648            );
1649            // When the launch URL is byte-identical to whatever the
1650            // user typed (Direct / no strip / no proxy), don't repeat
1651            // it — show a quiet "same url" placeholder instead. Long
1652            // URLs (proxied paths can balloon past a few hundred
1653            // chars) are capped at 1024 chars in the display; the
1654            // copy-on-tap path still hands back the full string.
1655            if (launchPreview.url === trimmed) {
1656              detailText = (
1657                <Text style={styles.urlHintPlaceholder}>Same URL</Text>
1658              );
1659            } else {
1660              const displayUrl = launchPreview.url.length > 1024
1661                ? launchPreview.url.slice(0, 1024) + '…'
1662                : launchPreview.url;
1663              // When proxied, paint the host (via) golden so the user
1664              // can see at a glance which part of the URL Warden
1665              // rewrote. Fallback to the plain render when the host
1666              // isn't found in the string (shouldn't happen, but
1667              // keeps the line safe against pathological URLs).
1668              if (launchPreview.via) {
1669                const hostIx = displayUrl.indexOf(launchPreview.via);
1670                if (hostIx >= 0) {
1671                  const before = displayUrl.slice(0, hostIx);
1672                  const host = displayUrl.slice(hostIx, hostIx + launchPreview.via.length);
1673                  const after = displayUrl.slice(hostIx + launchPreview.via.length);
1674                  detailText = (
1675                    <Text style={styles.urlHintLaunch}>
1676                      {before}
1677                      <Text style={styles.urlHintProxiedHost}>{host}</Text>
1678                      {after}
1679                    </Text>
1680                  );
1681                } else {
1682                  detailText = <Text style={styles.urlHintLaunch}>{displayUrl}</Text>;
1683                }
1684              } else {
1685                detailText = <Text style={styles.urlHintLaunch}>{displayUrl}</Text>;
1686              }
1687            }
1688            // Tap on the URL line: cycles to the next proxy instance
1689            // when one is available (cyclable proxy URL). Otherwise the
1690            // line is inert — copy-to-clipboard is no longer wired.
1691            if (canCycle) {
1692              detailPress = () => setProxyCycleIndex((i) => i + 1);
1693            }
1694            // Long-press shortcut into the matched proxy destination's
1695            // settings panel is still useful — kept proxied-only.
1696            if (launchPreview.via && launchPreview.matchedDest) {
1697              detailLongPress = () => setQuickProxyDestId(launchPreview.matchedDest!.id);
1698            }
1699          } else if (trimmed.length > 0) {
1700            iconName = 'alert-circle-outline';
1701            status = 'Valid URL required';
1702            // detailText keeps its "(Awaiting Valid URL)" default —
1703            // the link icon stays visible at all times, anchored by
1704            // this placeholder until a parseable URL lands.
1705          }
1706  
1707          return (
1708            <View style={styles.urlHintCol}>
1709              {/* Status line: state icon + label (with inline cycle
1710                  glyph when the proxy URL is cyclable — tap anywhere on
1711                  the label or glyph to cycle) on the left, Share Final
1712                  pushed to the right. */}
1713              <View style={styles.urlHintStatusLine}>
1714                {(() => {
1715                  // Cycle pill is grouped with the "Proxied" word so
1716                  // any trailing pieces ("· Stripped N tracking
1717                  // params") stay outside the cycle affordance — the
1718                  // pill marks the part you cycle, nothing else.
1719                  const pieceList = status.split(' · ');
1720                  const hasProxied = canCycle && pieceList[0] === 'Proxied';
1721                  const restText = hasProxied
1722                    ? pieceList.slice(1).join(' · ')
1723                    : '';
1724                  return (
1725                    <View style={styles.urlHintLine}>
1726                      <Ionicons name={iconName} size={11} color={iconColor} />
1727                      {hasProxied ? (
1728                        <>
1729                          {/* "Proxied" + cycle pill together form the
1730                              cycle affordance — one Pressable wraps
1731                              both so tapping the word or the icon both
1732                              advance the cycle. */}
1733                          <Pressable
1734                            onPress={() => setProxyCycleIndex((i) => i + 1)}
1735                            hitSlop={8}
1736                            accessibilityLabel="Cycle to next proxy hostname"
1737                            style={({ pressed }) => [
1738                              styles.urlHintProxiedGroup,
1739                              pressed && styles.urlHintLinePressed,
1740                            ]}>
1741                            <Text style={[styles.urlHintProxiedLabel, { color: labelColor }]}>
1742                              Proxied
1743                            </Text>
1744                            <Ionicons
1745                              name="sync-outline"
1746                              size={12}
1747                              color={Palette.highlight}
1748                            />
1749                          </Pressable>
1750                          {restText ? (
1751                            <Text style={[styles.urlHintProxiedLabel, { color: labelColor }]}>
1752                              {` · ${restText}`}
1753                            </Text>
1754                          ) : null}
1755                        </>
1756                      ) : (
1757                        <Text style={[styles.urlHintProxiedLabel, { color: labelColor }]}>
1758                          {status}
1759                        </Text>
1760                      )}
1761                    </View>
1762                  );
1763                })()}
1764                <View style={styles.urlHintActions}>
1765                  <Pressable
1766                    onPress={async () => {
1767                      const finalUrl = launchPreview?.url ?? null;
1768                      if (!finalUrl) return;
1769                      Keyboard.dismiss();
1770                      try { await Share.share({ message: finalUrl, url: finalUrl }); } catch {}
1771                    }}
1772                    disabled={!launchPreview?.url}
1773                    hitSlop={8}
1774                    accessibilityLabel="Share final link"
1775                    style={({ pressed }) => [
1776                      styles.urlHintShareBtn,
1777                      !launchPreview?.url && styles.urlHintShareDisabled,
1778                      launchPreview?.url && pressed && styles.iconBtnPressed,
1779                    ]}>
1780                    <Ionicons
1781                      name="share-social-outline"
1782                      size={11}
1783                      color={launchPreview?.url ? Palette.accentBright : Palette.textMuted}
1784                    />
1785                    <Text
1786                      style={[
1787                        styles.urlHintShareBtnText,
1788                        !launchPreview?.url && { color: Palette.textMuted },
1789                      ]}>
1790                      Final
1791                    </Text>
1792                  </Pressable>
1793                </View>
1794              </View>
1795              {/* URL line — link icon + final-URL text. Full row
1796                  width now that the action buttons moved up to the
1797                  status line. Inert when there's no parseable URL
1798                  (placeholder text only); copy-on-tap in the valid
1799                  / proxied / flag states. */}
1800              <Pressable
1801                onPress={detailPress}
1802                onLongPress={detailLongPress}
1803                disabled={!detailPress}
1804                style={({ pressed }) => [
1805                  styles.urlHintLine,
1806                  detailPress && pressed && styles.urlHintLinePressed,
1807                ]}>
1808                <Ionicons
1809                  name="arrow-forward"
1810                  size={11}
1811                  color={Palette.textMuted}
1812                />
1813                <Text style={styles.urlHintText}>
1814                  {detailText}
1815                </Text>
1816              </Pressable>
1817            </View>
1818          );
1819        })() : null}
1820        </View>
1821  
1822        {/* Rune divider between LINK and FLOWS — mirrors the one
1823            between FLOWS and the bottom action row. ᛉ ᛟ ᛉ:
1824            Protection · Inheritance · Protection. Trimmed top margin
1825            so the URL section sits closer to the divider than the
1826            Flows-section breathing-room below it. */}
1827        <View style={[styles.runeDivider, { marginTop: 12 }]}>
1828          <View style={styles.runeDividerLine} />
1829          <Text style={styles.runeDividerGlyph}>ᛉ  ᛟ  ᛉ</Text>
1830          <View style={styles.runeDividerLine} />
1831        </View>
1832  
1833        <View style={styles.bottomGroup}>
1834        {/* FLOWS scope brackets temporarily disabled — comparing how
1835            the section reads with the LINK frame above standing alone.
1836            Re-add the four scopeCorner Views to restore. */}
1837        {/* Flows — saved (browser × mode × extras) recipes that each
1838            open the current URL with one tap. */}
1839  
1840        {/* Profile tab selector */}
1841        <View style={styles.profileTabs}>
1842          {PROFILES.map((p) => {
1843            const active = p === activeProfile;
1844            const count = flows.filter((f) => f.profile === p).length;
1845            return (
1846              <Pressable
1847                key={p}
1848                onPress={() => setActiveProfile(p)}
1849                style={({ pressed }) => [
1850                  styles.profileTab,
1851                  active && styles.profileTabActive,
1852                  pressed && styles.tabPressed,
1853                ]}>
1854                <Text style={[
1855                  styles.profileTabText,
1856                  active && styles.profileTabTextActive,
1857                ]}>
1858                  {PROFILE_LABEL[p]}
1859                </Text>
1860                {count > 0 ? (
1861                  <View style={[
1862                    styles.profileTabCount,
1863                    active && styles.profileTabCountActive,
1864                  ]}>
1865                    <Text style={[
1866                      styles.profileTabCountText,
1867                      active && styles.profileTabCountTextActive,
1868                    ]}>{count}</Text>
1869                  </View>
1870                ) : null}
1871              </Pressable>
1872            );
1873          })}
1874        </View>
1875  
1876        <View style={[styles.list, styles.flowsListAfterLink]}>
1877          {flows.filter((f) => f.profile === activeProfile).length === 0 ? (
1878            <Pressable
1879              onPress={() => setNewFlowPickerOpen(true)}
1880              accessibilityLabel="Create your first Flow"
1881              style={({ pressed }) => [
1882                styles.onboardingCard,
1883                pressed && styles.onboardingCardPressed,
1884              ]}>
1885              {/* ᛉ algiz — the rune for protection / warding. */}
1886              <Text style={styles.onboardingRune}>ᛉ</Text>
1887              <Text style={styles.onboardingTitle}>No flows yet</Text>
1888              <Text style={styles.onboardingBody}>Create a flow to get started</Text>
1889            </Pressable>
1890          ) : (
1891            flows.filter((f) => f.profile === activeProfile).map((f) => {
1892              const b = BROWSERS.find((x) => x.pkg === f.browserPkg);
1893              // Four modes for the right (launch) side:
1894              //  - Normal URL: tap fires openInBrowser. Disabled when
1895              //    the field doesn't normalise.
1896              //  - @config placeholder: tap shares this browser's own
1897              //    flags page (b.configUrl). Disabled for browsers
1898              //    that don't expose one (Focus, DDG).
1899              //  - Manually-typed flag link (chrome://, about:…): tap
1900              //    shares the URL verbatim. Never disabled.
1901              //  - Legacy browser-config sentinel: kept for old state;
1902              //    same disable rule as @config.
1903              // Left side stays active either way so the user can still
1904              // edit the Flow or long-press for the action sheet.
1905              const isConfigPlaceholderMode = target === CONFIG_PLACEHOLDER;
1906              const isLegacySentinel = target === BROWSER_CONFIG_SENTINEL;
1907              const isFlagMode = isFlagLinkUrl(target);
1908              const isShareMode = isConfigPlaceholderMode || isLegacySentinel || isFlagMode;
1909              const launchDisabled = (isConfigPlaceholderMode || isLegacySentinel)
1910                ? !b?.configUrl
1911                : isFlagMode
1912                  ? false
1913                  : !normalizeUrl(target);
1914              // Row name is just the browser name now — the Flow's `title`
1915              // field has been repurposed as a free-form Tag and surfaces
1916              // as a small pill below.
1917              const rowName = formatRunicName(b?.name ?? f.browserPkg, runicNames);
1918              const tag = (f.title ?? '').trim();
1919              return (
1920                <View key={f.id} style={styles.rowOuter}>
1921                  <Pressable
1922                    onPress={() => setEditingFlow(f)}
1923                    onLongPress={() => setEditingFlow(f)}
1924                    delayLongPress={400}
1925                    style={({ pressed }) => [
1926                      styles.rowLeft,
1927                      pressed && styles.rowPressed,
1928                    ]}>
1929                    {f.profile !== 'privacy' ? (
1930                      <View style={[
1931                        styles.profileBadge,
1932                        f.profile === 'raw' && styles.profileBadgeRaw,
1933                        f.profile === 'work' && styles.profileBadgeWork,
1934                      ]} />
1935                    ) : null}
1936                    {b && ASSET_ICONS[b.id] ? (
1937                      <Image source={ASSET_ICONS[b.id]} style={styles.icon} />
1938                    ) : b && icons[b.pkg] ? (
1939                      <Image source={{ uri: icons[b.pkg] }} style={styles.icon} />
1940                    ) : (
1941                      <View
1942                        style={[
1943                          styles.swatch,
1944                          { backgroundColor: b?.tint ?? Palette.bgElevated },
1945                        ]}
1946                      />
1947                    )}
1948                    <View style={styles.rowText}>
1949                      <View style={styles.rowNameRow}>
1950                        <Text style={styles.rowName} numberOfLines={1}>
1951                          {rowName}
1952                        </Text>
1953                        {tag ? (
1954                          <View style={styles.flowTagPill}>
1955                            <Text style={styles.flowTagPillText} numberOfLines={1}>
1956                              {tag}
1957                            </Text>
1958                          </View>
1959                        ) : null}
1960                        {f.autoFire ? (
1961                          <View style={styles.autolaunchBadge}>
1962                            <Ionicons name="flash" size={11} color="#f6c84c" />
1963                          </View>
1964                        ) : null}
1965                      </View>
1966                      <FlowModePills flow={f} />
1967                      {b ? <RatingsRow browser={b} /> : null}
1968                    </View>
1969                  </Pressable>
1970                  <Pressable
1971                    disabled={launchDisabled}
1972                    onPress={() => handleOpenFlow(f)}
1973                    onLongPress={() => setOpenOptionsFlow(f)}
1974                    delayLongPress={400}
1975                    style={({ pressed }) => [
1976                      styles.rowRight,
1977                      launchDisabled && styles.rowDisabled,
1978                      pressed && styles.rowPressed,
1979                    ]}>
1980                    <Ionicons
1981                      name={
1982                        isShareMode
1983                          ? 'share-outline'
1984                          : isMaxPrivacy(f) ? 'glasses' : 'globe-outline'
1985                      }
1986                      size={20}
1987                      color={Palette.accentBright}
1988                    />
1989                    <Text style={styles.rowRightLabel} numberOfLines={2}>
1990                      {isConfigPlaceholderMode || isLegacySentinel
1991                        ? (b?.configUrl ?? 'No flags')
1992                        : isFlagMode
1993                          ? 'Share\nlink'
1994                          : isMaxPrivacy(f) ? 'Open\nIncognito' : 'Open'}
1995                    </Text>
1996                  </Pressable>
1997                </View>
1998              );
1999            })
2000          )}
2001        </View>
2002        </View>
2003  
2004        {/* Viking-style rune divider between the FLOWS section and the
2005            bottom action row. Centered runes read ᛉ ᛟ ᛉ — Protection
2006            (algiz) flanking Inheritance (othala). Pure Unicode glyphs
2007            at accentDeep with wide letter-spacing so they read as a
2008            small carved ornament. */}
2009        <View style={styles.runeDivider}>
2010          <View style={styles.runeDividerLine} />
2011          <Text style={styles.runeDividerGlyph}>ᛉ  ᛟ  ᛉ</Text>
2012          <View style={styles.runeDividerLine} />
2013        </View>
2014  
2015        {/* Bottom action row — + New Flow only. Share Final lives in
2016            the status row above; no duplicate here. */}
2017        <View style={styles.bottomActionRow}>
2018          <Pressable
2019            onPress={() => setNewFlowPickerOpen(true)}
2020            style={({ pressed }) => [
2021              styles.newProfileBtn,
2022              pressed && styles.tabPressed,
2023            ]}>
2024            <Ionicons name="add" size={14} color={Palette.accentBright} />
2025            <Text style={styles.newProfileBtnText}>New Flow</Text>
2026          </Pressable>
2027        </View>
2028        </View>
2029  
2030        {errorMsg ? (
2031          <View style={styles.errorBox}>
2032            <Text style={styles.errorText}>{errorMsg}</Text>
2033          </View>
2034        ) : null}
2035  
2036        <FlowEditorModal
2037          flow={editingFlow}
2038          installed={installed}
2039          icons={icons}
2040          onSave={(f) => {
2041            upsertFlow(f);
2042            setEditingFlow(null);
2043          }}
2044          onDelete={(id) => confirmDeleteFlow(id, () => setEditingFlow(null))}
2045          isExisting={editingFlow ? flows.some((f) => f.id === editingFlow.id) : false}
2046          canMoveUp={
2047            editingFlow ? flows.findIndex((f) => f.id === editingFlow.id) > 0 : false
2048          }
2049          canMoveDown={
2050            editingFlow
2051              ? (() => {
2052                  const i = flows.findIndex((f) => f.id === editingFlow.id);
2053                  return i >= 0 && i < flows.length - 1;
2054                })()
2055              : false
2056          }
2057          onMove={(d) => {
2058            if (editingFlow) moveFlow(editingFlow.id, d);
2059          }}
2060          onClose={() => setEditingFlow(null)}
2061        />
2062  
2063        <FlowActionSheet
2064          // Re-derive from the live flows array so the sheet reflects any
2065          // in-place mutations without closing.
2066          flow={actionFlow ? flows.find((f) => f.id === actionFlow.id) ?? null : null}
2067          canMoveUp={
2068            actionFlow ? flows.findIndex((f) => f.id === actionFlow.id) > 0 : false
2069          }
2070          canMoveDown={
2071            actionFlow
2072              ? flows.findIndex((f) => f.id === actionFlow.id) < flows.length - 1
2073              : false
2074          }
2075          onEdit={() => {
2076            const f = actionFlow;
2077            setActionFlow(null);
2078            if (f) setEditingFlow(f);
2079          }}
2080          onMove={(d) => {
2081            if (actionFlow) moveFlow(actionFlow.id, d);
2082          }}
2083          onClose={() => setActionFlow(null)}
2084        />
2085  
2086        <OpenOptionsMenu
2087          flow={openOptionsFlow}
2088          onOpen={() => {
2089            const f = openOptionsFlow;
2090            setOpenOptionsFlow(null);
2091            if (f) handleOpenFlow(f);
2092          }}
2093          onOpenNormal={() => {
2094            const f = openOptionsFlow;
2095            setOpenOptionsFlow(null);
2096            // Strip extras + force normal mode — bypasses any privacy
2097            // configuration on the Flow for this one launch.
2098            if (f) handleOpenFlow({ ...f, mode: 'normal', extras: [] });
2099          }}
2100          onClose={() => setOpenOptionsFlow(null)}
2101        />
2102  
2103        {/* Step 1 of New Flow — pick a browser. The editor opens once a
2104            browser is committed, pre-populated. */}
2105        <BrowserPicker
2106          visible={newFlowPickerOpen}
2107          installed={installed}
2108          icons={icons}
2109          selected=""
2110          onPick={(pkg) => {
2111            setNewFlowPickerOpen(false);
2112            setEditingFlow(startNewFlow(pkg));
2113          }}
2114          onClose={() => setNewFlowPickerOpen(false)}
2115        />
2116  
2117        <AboutModal visible={aboutOpen} onClose={() => setAboutOpen(false)} />
2118  
2119        <DonateModal
2120          visible={donateOpen}
2121          onClose={() => setDonateOpen(false)}
2122          onPickUrl={(url) => {
2123            setTarget(url);
2124            setErrorMsg(null);
2125            setDonateOpen(false);
2126          }}
2127        />
2128  
2129        <SettingsModal
2130          visible={settingsOpen}
2131          stripTracking={stripTracking}
2132          onSetStripTracking={updateStripTracking}
2133          runicNames={runicNames}
2134          onSetRunicNames={updateRunicNames}
2135          showPaste={showPaste}
2136          onSetShowPaste={updateShowPaste}
2137          showClear={showClear}
2138          onSetShowClear={updateShowClear}
2139          proxyConfig={proxyConfig}
2140          onSetProxyConfig={updateProxyConfig}
2141          onClose={() => setSettingsOpen(false)}
2142        />
2143  
2144        {/* Quick-edit shortcut for the proxied destination — opened by
2145            long-pressing the launch URL in the status panel. Resolves
2146            the id to either the built-in destination modal or the
2147            custom one. */}
2148        {(() => {
2149          if (!quickProxyDestId) return null;
2150          const builtin = PROXY_DESTINATIONS.find((d) => d.id === quickProxyDestId);
2151          if (builtin) {
2152            return (
2153              <ProxyDestinationModal
2154                dest={builtin}
2155                proxyConfig={proxyConfig}
2156                onSetProxyConfig={updateProxyConfig}
2157                onClose={() => setQuickProxyDestId(null)}
2158              />
2159            );
2160          }
2161          const custom = proxyConfig.customDests.find((c) => c.id === quickProxyDestId);
2162          if (custom) {
2163            return (
2164              <CustomProxyDestinationModal
2165                dest={custom}
2166                proxyConfig={proxyConfig}
2167                onSetProxyConfig={updateProxyConfig}
2168                onClose={() => setQuickProxyDestId(null)}
2169              />
2170            );
2171          }
2172          return null;
2173        })()}
2174      </ScrollView>
2175      </RunicNamesContext.Provider>
2176    );
2177  }
2178  
2179  /**
2180   * "What is Warden?" — opened from the info button in the top bar.
2181   * Mirrors the README's pillars so the in-app pitch and the repo readme
2182   * stay aligned. Pure text + iconography, no settings inside.
2183   */
2184  /**
2185   * Modal version of the URL-presets picker. Replaces the earlier inline
2186   * dropdown so the picker lives on top of the screen rather than pushing
2187   * the Flow list down. Two sections:
2188   *   - Browser config — sets the BROWSER_CONFIG_SENTINEL.
2189   *   - Browser checks — a list of URLs known to expose specific behavior
2190   *     (incognito detection, fingerprinting tests, …).
2191   */
2192  /**
2193   * Long-press menu for the right (launch) side of a Flow row. Two options:
2194   *  - Open: fires the Flow as-configured (its mode + extras).
2195   *  - Open Normal: bypasses the privacy mode entirely — fresh tab, no
2196   *    incognito/ephemeral, no custom extras.
2197   * Mounted by Home() and driven by the `openOptionsFlow` state.
2198   */
2199  function OpenOptionsMenu({
2200    flow,
2201    onOpen,
2202    onOpenNormal,
2203    onClose,
2204  }: {
2205    flow: Flow | null;
2206    onOpen: () => void;
2207    onOpenNormal: () => void;
2208    onClose: () => void;
2209  }) {
2210    const b = flow ? BROWSERS.find((x) => x.pkg === flow.browserPkg) : null;
2211    const defaultLabel = flow && isMaxPrivacy(flow) ? 'Open · Incognito' : 'Open';
2212    return (
2213      <Modal visible={flow !== null} transparent animationType="fade" onRequestClose={onClose}>
2214        <Pressable style={modalStyles.backdrop} onPress={onClose}>
2215          <Pressable style={[modalStyles.sheet, openOptionsStyles.sheet]} onPress={() => {}}>
2216            <Text style={modalStyles.title}>{b?.name ?? flow?.browserPkg ?? 'Open'}</Text>
2217            <Text style={modalStyles.subtitle}>How do you want to open it?</Text>
2218  
2219            <Pressable
2220              onPress={onOpen}
2221              style={({ pressed }) => [
2222                openOptionsStyles.row,
2223                openOptionsStyles.primary,
2224                pressed && modalStyles.actionBtnPressed,
2225              ]}>
2226              <Ionicons
2227                name={flow && isMaxPrivacy(flow) ? 'glasses' : 'globe-outline'}
2228                size={20}
2229                color={Palette.text}
2230              />
2231              <View style={openOptionsStyles.rowText}>
2232                <Text style={openOptionsStyles.rowLabel}>{defaultLabel}</Text>
2233                <Text style={openOptionsStyles.rowHint}>
2234                  Use this Flow's configured mode + extras
2235                </Text>
2236              </View>
2237              <Ionicons name="chevron-forward" size={14} color={Palette.text} />
2238            </Pressable>
2239  
2240            <Pressable
2241              onPress={onOpenNormal}
2242              style={({ pressed }) => [
2243                openOptionsStyles.row,
2244                openOptionsStyles.secondary,
2245                pressed && modalStyles.actionBtnPressed,
2246              ]}>
2247              <Ionicons name="open-outline" size={20} color={Palette.accentBright} />
2248              <View style={openOptionsStyles.rowText}>
2249                <Text style={[openOptionsStyles.rowLabel, openOptionsStyles.rowLabelLight]}>
2250                  Open Normal
2251                </Text>
2252                <Text style={[openOptionsStyles.rowHint, openOptionsStyles.rowHintLight]}>
2253                  Plain tab — no incognito, no extras
2254                </Text>
2255              </View>
2256              <Ionicons name="chevron-forward" size={14} color={Palette.textMuted} />
2257            </Pressable>
2258  
2259            <View style={modalStyles.explainCloseRow}>
2260              <View style={modalStyles.cycleBtnSpacer} />
2261              <Pressable
2262                onPress={onClose}
2263                style={({ pressed }) => [
2264                  editorStyles.cancelBtn,
2265                  pressed && modalStyles.actionBtnPressed,
2266                ]}>
2267                <Text style={modalStyles.closeBtnText}>Cancel</Text>
2268              </Pressable>
2269              <View style={modalStyles.cycleBtnSpacer} />
2270            </View>
2271          </Pressable>
2272        </Pressable>
2273      </Modal>
2274    );
2275  }
2276  
2277  const openOptionsStyles = StyleSheet.create({
2278    sheet: {
2279      // Slightly narrower than the default sheet — this menu is intentionally
2280      // small (two options + Cancel) and shouldn't sprawl.
2281      maxWidth: 360,
2282    },
2283    row: {
2284      flexDirection: 'row',
2285      alignItems: 'center',
2286      gap: 12,
2287      paddingVertical: 12,
2288      paddingHorizontal: 14,
2289      borderRadius: 10,
2290      marginTop: 8,
2291      borderWidth: 1,
2292    },
2293    primary: {
2294      backgroundColor: Palette.highlight,
2295      borderColor: Palette.highlight,
2296    },
2297    secondary: {
2298      backgroundColor: Palette.bgElevated,
2299      borderColor: Palette.border,
2300    },
2301    rowText: { flex: 1 },
2302    rowLabel: {
2303      fontSize: 14,
2304      fontWeight: '700',
2305      color: Palette.bg,
2306      letterSpacing: 0.2,
2307    },
2308    rowLabelLight: { color: Palette.text },
2309    rowHint: {
2310      fontSize: 11,
2311      color: Palette.bg,
2312      opacity: 0.7,
2313      marginTop: 2,
2314    },
2315    rowHintLight: { color: Palette.textMuted, opacity: 1 },
2316  });
2317  
2318  function PresetsModal({
2319    visible,
2320    customPresets,
2321    onSetCustomPresets,
2322    onClose,
2323    onPickUrl,
2324  }: {
2325    visible: boolean;
2326    customPresets: string[];
2327    onSetCustomPresets: (next: string[]) => void;
2328    onClose: () => void;
2329    onPickUrl: (url: string) => void;
2330  }) {
2331    const [openGroup, setOpenGroup] = useState<PresetGroup | null>(null);
2332    const [customInput, setCustomInput] = useState('');
2333    const [customError, setCustomError] = useState<string | null>(null);
2334    // Tap-to-expand state for the Add URL composer. When false, a
2335    // single "+ Add URL" pill is shown; when true, the composer
2336    // (input + cancel + confirm) takes over.
2337    const [addingCustom, setAddingCustom] = useState(false);
2338    // Long-press target for the custom-preset action sheet (move/delete).
2339    const [actionUrl, setActionUrl] = useState<string | null>(null);
2340    // Tab strip — Custom takes the leading position when the user
2341    // Custom always leads and is the default active tab.
2342    const [activeTab, setActiveTab] = useState<'checks' | 'custom'>('custom');
2343    useEffect(() => {
2344      if (visible) setActiveTab('custom');
2345      // eslint-disable-next-line react-hooks/exhaustive-deps
2346    }, [visible]);
2347  
2348    const addCustomPreset = () => {
2349      const url = normalizeUrl(customInput);
2350      if (!url) {
2351        setCustomError('Enter a valid URL (e.g. example.com or https://…)');
2352        return;
2353      }
2354      if (customPresets.includes(url)) {
2355        setCustomError('Already in the list');
2356        return;
2357      }
2358      onSetCustomPresets([...customPresets, url]);
2359      setCustomInput('');
2360      setCustomError(null);
2361      setAddingCustom(false);
2362    };
2363    const cancelAddCustom = () => {
2364      setCustomInput('');
2365      setCustomError(null);
2366      setAddingCustom(false);
2367    };
2368    const removeCustomPreset = (url: string) => {
2369      onSetCustomPresets(customPresets.filter((u) => u !== url));
2370    };
2371    const moveCustomPreset = (url: string, delta: -1 | 1) => {
2372      const i = customPresets.indexOf(url);
2373      if (i < 0) return;
2374      const j = i + delta;
2375      if (j < 0 || j >= customPresets.length) return;
2376      const next = [...customPresets];
2377      [next[i], next[j]] = [next[j], next[i]];
2378      onSetCustomPresets(next);
2379    };
2380    /**
2381     * Edit a custom preset in place. Validates the new value through
2382     * normalizeUrl; if it parses, the entry at the original position
2383     * is swapped (order preserved). Returns null on success or an
2384     * error string the action sheet can surface inline.
2385     */
2386    const editCustomPreset = (oldUrl: string, draft: string): string | null => {
2387      const next = normalizeUrl(draft);
2388      if (!next) return 'Enter a valid URL (e.g. example.com or https://…)';
2389      if (next === oldUrl) return null;
2390      if (customPresets.includes(next)) return 'Already in the list';
2391      const i = customPresets.indexOf(oldUrl);
2392      if (i < 0) return null;
2393      const arr = [...customPresets];
2394      arr[i] = next;
2395      onSetCustomPresets(arr);
2396      return null;
2397    };
2398    const confirmDeleteCustomPreset = (url: string) => {
2399      Alert.alert(
2400        `Delete "${url}"?`,
2401        'This cannot be undone.',
2402        [
2403          { text: 'Cancel', style: 'cancel' },
2404          {
2405            text: 'Delete',
2406            style: 'destructive',
2407            onPress: () => { removeCustomPreset(url); setActionUrl(null); },
2408          },
2409        ],
2410      );
2411    };
2412    return (
2413      <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
2414        <Pressable style={modalStyles.backdrop} onPress={onClose}>
2415          <KeyboardAvoidingView
2416            behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
2417            style={modalStyles.keyboardWrap}
2418            pointerEvents="box-none">
2419          <Pressable style={modalStyles.sheet} onPress={() => {}}>
2420            {/* Elegant header — icon + title + close, matching the
2421                encyclopedia's chrome. */}
2422            <View style={modalStyles.headerSticky}>
2423              <View style={[modalStyles.headerIcon, modalStyles.glossaryHeaderIcon]}>
2424                <Ionicons name="bookmarks-outline" size={20} color={Palette.highlight} />
2425              </View>
2426              <View style={modalStyles.headerText}>
2427                <Text style={modalStyles.encTitle}>Presets</Text>
2428                <Text style={modalStyles.encSubtitle}>Saved & diagnostic links</Text>
2429              </View>
2430              <Pressable
2431                onPress={onClose}
2432                hitSlop={10}
2433                accessibilityLabel="Close"
2434                style={({ pressed }) => [
2435                  modalStyles.headerCloseBtn,
2436                  pressed && modalStyles.actionBtnPressed,
2437                ]}>
2438                <Ionicons name="close" size={20} color={Palette.textMuted} />
2439              </Pressable>
2440            </View>
2441  
2442            {/* Tab strip — Custom always leads (segmented pills, same
2443                vocabulary as the encyclopedia tabs). */}
2444            <View style={modalStyles.encTabStrip}>
2445              {(['custom', 'checks'] as const).map((id) => (
2446                <Pressable
2447                  key={id}
2448                  onPress={() => setActiveTab(id)}
2449                  style={({ pressed }) => [
2450                    modalStyles.encTab,
2451                    activeTab === id && modalStyles.encTabActive,
2452                    pressed && modalStyles.actionBtnPressed,
2453                  ]}>
2454                  <Ionicons
2455                    name={id === 'custom' ? 'star-outline' : 'flask-outline'}
2456                    size={14}
2457                    color={activeTab === id ? Palette.text : Palette.textMuted}
2458                  />
2459                  <Text
2460                    style={[
2461                      modalStyles.encTabText,
2462                      activeTab === id && modalStyles.encTabTextActive,
2463                    ]}>
2464                    {id === 'custom' ? 'Custom' : 'Checks & Config'}
2465                  </Text>
2466                </Pressable>
2467              ))}
2468            </View>
2469  
2470            <ScrollView
2471              style={[styles.presetsScroll, modalStyles.presetsScrollPanel]}
2472              contentContainerStyle={styles.presetsScrollContent}
2473              showsVerticalScrollIndicator>
2474              {activeTab === 'checks' ? (
2475              <>
2476              {/* Each preset group is rendered as a single forward-navigation
2477                  row. Tapping a row opens that group's link list in a
2478                  nested modal. */}
2479              {PRESET_GROUPS.map((group) => (
2480                <Pressable
2481                  key={group.id}
2482                  onPress={() => setOpenGroup(group)}
2483                  style={({ pressed }) => [
2484                    styles.testsRow,
2485                    styles.testsRowGroup,
2486                    pressed && styles.testsRowPressed,
2487                  ]}>
2488                  <Ionicons
2489                    name="layers-outline"
2490                    size={16}
2491                    color={Palette.accentBright}
2492                    style={styles.presetIcon}
2493                  />
2494                  <View style={styles.testsRowText}>
2495                    <Text style={styles.testsRowLabel}>{group.label}</Text>
2496                    <Text style={styles.testsRowHint} numberOfLines={1}>
2497                      {group.items.length} link{group.items.length === 1 ? '' : 's'}
2498                    </Text>
2499                  </View>
2500                  <Ionicons name="chevron-forward" size={14} color={Palette.accentBright} />
2501                </Pressable>
2502              ))}
2503              </>
2504              ) : (
2505              /* Custom tab — user-added URLs render flat (no submenu
2506                 drill-down). Same bordered panel as before so reopening
2507                 the modal feels continuous. */
2508              <View style={styles.presetCustomPanel}>
2509                {/* Tab label already reads "Custom" — drop the in-panel
2510                    duplicate header, keep just the Add URL chip on the
2511                    right edge when there's at least one row already. */}
2512                {!addingCustom && customPresets.length > 0 ? (
2513                  <View style={styles.presetCustomHeaderRow}>
2514                    <Pressable
2515                      onPress={() => setAddingCustom(true)}
2516                      hitSlop={6}
2517                      style={({ pressed }) => [
2518                        styles.presetCustomAddChip,
2519                        pressed && modalStyles.actionBtnPressed,
2520                      ]}>
2521                      <Ionicons name="add" size={12} color={Palette.highlight} />
2522                      <Text style={styles.presetCustomAddChipText}>Add URL</Text>
2523                    </Pressable>
2524                  </View>
2525                ) : null}
2526                {customPresets.length === 0 ? (
2527                  !addingCustom ? (
2528                    // Empty state: a single centered "+ Add URL" CTA
2529                    // replaces the previous "No custom URLs yet" hint +
2530                    // small header chip combo — one obvious thing to do
2531                    // when there's nothing here yet.
2532                    <Pressable
2533                      onPress={() => setAddingCustom(true)}
2534                      hitSlop={6}
2535                      style={({ pressed }) => [
2536                        styles.presetCustomEmptyCta,
2537                        pressed && modalStyles.actionBtnPressed,
2538                      ]}>
2539                      <Ionicons name="add" size={14} color={Palette.highlight} />
2540                      <Text style={styles.presetCustomEmptyCtaText}>Add URL</Text>
2541                    </Pressable>
2542                  ) : null
2543                ) : (
2544                  <>
2545                    {customPresets.map((url) => (
2546                      <Pressable
2547                        key={url}
2548                        onPress={() => onPickUrl(url)}
2549                        onLongPress={() => setActionUrl(url)}
2550                        delayLongPress={400}
2551                        style={({ pressed }) => [styles.testsRow, pressed && styles.testsRowPressed]}>
2552                        <Ionicons
2553                          name="link-outline"
2554                          size={16}
2555                          color={Palette.accentBright}
2556                          style={styles.presetIcon}
2557                        />
2558                        <View style={styles.testsRowText}>
2559                          <Text style={styles.testsRowHint} numberOfLines={1}>
2560                            {url}
2561                          </Text>
2562                        </View>
2563                      </Pressable>
2564                    ))}
2565                    {/* Quiet hint at the foot of the list — only shown
2566                        when there's at least one row, so the affordance
2567                        reads against an actual long-press target. */}
2568                    <Text style={styles.presetCustomFootHint}>
2569                      Long-press a link for options
2570                    </Text>
2571                  </>
2572                )}
2573  
2574                {addingCustom ? (
2575                  <>
2576                    <View style={styles.presetCustomAddRow}>
2577                      <TextInput
2578                        value={customInput}
2579                        onChangeText={(t) => { setCustomInput(t); if (customError) setCustomError(null); }}
2580                        onSubmitEditing={addCustomPreset}
2581                        placeholder="example.com or https://…"
2582                        placeholderTextColor={Palette.textMuted}
2583                        autoCapitalize="none"
2584                        autoCorrect={false}
2585                        keyboardType="url"
2586                        style={styles.presetCustomInput}
2587                        autoFocus
2588                      />
2589                      <Pressable
2590                        onPress={cancelAddCustom}
2591                        hitSlop={6}
2592                        style={({ pressed }) => [
2593                          styles.presetCustomAddBtn,
2594                          { borderColor: Palette.border },
2595                          pressed && modalStyles.actionBtnPressed,
2596                        ]}>
2597                        <Ionicons name="close" size={14} color={Palette.textMuted} />
2598                      </Pressable>
2599                      <Pressable
2600                        onPress={addCustomPreset}
2601                        disabled={customInput.trim().length === 0}
2602                        style={({ pressed }) => [
2603                          styles.presetCustomAddBtn,
2604                          customInput.trim().length === 0 && { opacity: 0.4 },
2605                          pressed && modalStyles.actionBtnPressed,
2606                        ]}>
2607                        <Ionicons name="checkmark" size={16} color={Palette.highlight} />
2608                      </Pressable>
2609                    </View>
2610                    {customError ? (
2611                      <Text style={styles.presetCustomError}>{customError}</Text>
2612                    ) : null}
2613                  </>
2614                ) : null}
2615              </View>
2616              )}
2617            </ScrollView>
2618          </Pressable>
2619          </KeyboardAvoidingView>
2620        </Pressable>
2621  
2622        <PresetGroupModal
2623          group={openGroup}
2624          onClose={() => setOpenGroup(null)}
2625          onPickUrl={(url) => {
2626            setOpenGroup(null);
2627            onPickUrl(url);
2628          }}
2629        />
2630  
2631        <CustomPresetActionSheet
2632          url={actionUrl}
2633          canMoveUp={actionUrl ? customPresets.indexOf(actionUrl) > 0 : false}
2634          canMoveDown={
2635            actionUrl
2636              ? customPresets.indexOf(actionUrl) < customPresets.length - 1
2637              : false
2638          }
2639          onMove={(d) => { if (actionUrl) moveCustomPreset(actionUrl, d); }}
2640          onEdit={(draft) => {
2641            if (!actionUrl) return null;
2642            const err = editCustomPreset(actionUrl, draft);
2643            if (!err) setActionUrl(null);
2644            return err;
2645          }}
2646          onDelete={() => { if (actionUrl) confirmDeleteCustomPreset(actionUrl); }}
2647          onClose={() => setActionUrl(null)}
2648        />
2649      </Modal>
2650    );
2651  }
2652  
2653  /**
2654   * Long-press action sheet for a single custom preset URL. Mirrors the
2655   * FlowActionSheet shape — Move up / Move down / Delete (confirmed) /
2656   * Close — but the only header is the URL itself.
2657   */
2658  function CustomPresetActionSheet({
2659    url, canMoveUp, canMoveDown,
2660    onMove, onEdit, onDelete, onClose,
2661  }: {
2662    url: string | null;
2663    canMoveUp: boolean;
2664    canMoveDown: boolean;
2665    onMove: (delta: -1 | 1) => void;
2666    /** Save handler — returns null on success, or an error string the
2667     *  sheet should surface inline (e.g. invalid URL / duplicate). */
2668    onEdit: (draft: string) => string | null;
2669    onDelete: () => void;
2670    onClose: () => void;
2671  }) {
2672    const [editing, setEditing] = useState(false);
2673    const [draft, setDraft] = useState('');
2674    const [editError, setEditError] = useState<string | null>(null);
2675  
2676    // Reset edit state whenever the sheet opens against a different URL.
2677    useEffect(() => {
2678      if (url) { setDraft(url); setEditing(false); setEditError(null); }
2679    }, [url]);
2680  
2681    if (!url) {
2682      return <Modal visible={false} transparent onRequestClose={onClose}><View /></Modal>;
2683    }
2684  
2685    const commitEdit = () => {
2686      const err = onEdit(draft);
2687      if (err) setEditError(err);
2688    };
2689  
2690    return (
2691      <Modal visible transparent animationType="fade" onRequestClose={onClose}>
2692        <Pressable style={modalStyles.backdrop} onPress={onClose}>
2693          <Pressable style={modalStyles.sheet} onPress={() => {}}>
2694            <View style={modalStyles.header}>
2695              <Ionicons
2696                name="link-outline"
2697                size={18}
2698                color={Palette.highlight}
2699              />
2700              <View style={modalStyles.headerText}>
2701                <Text style={modalStyles.title} numberOfLines={1}>
2702                  Custom URL
2703                </Text>
2704                <Text style={modalStyles.subtitle} numberOfLines={1}>
2705                  {url}
2706                </Text>
2707              </View>
2708            </View>
2709  
2710            {editing ? (
2711              <View style={modalStyles.actionList}>
2712                <View style={styles.presetCustomAddRow}>
2713                  <TextInput
2714                    value={draft}
2715                    onChangeText={(t) => { setDraft(t); if (editError) setEditError(null); }}
2716                    onSubmitEditing={commitEdit}
2717                    placeholder="https://example.com"
2718                    placeholderTextColor={Palette.textMuted}
2719                    autoCapitalize="none"
2720                    autoCorrect={false}
2721                    keyboardType="url"
2722                    style={styles.presetCustomInput}
2723                    autoFocus
2724                  />
2725                  <Pressable
2726                    onPress={() => { setEditing(false); setEditError(null); setDraft(url); }}
2727                    hitSlop={6}
2728                    style={({ pressed }) => [
2729                      styles.presetCustomAddBtn,
2730                      { borderColor: Palette.border },
2731                      pressed && modalStyles.actionBtnPressed,
2732                    ]}>
2733                    <Ionicons name="close" size={14} color={Palette.textMuted} />
2734                  </Pressable>
2735                  <Pressable
2736                    onPress={commitEdit}
2737                    disabled={draft.trim().length === 0}
2738                    style={({ pressed }) => [
2739                      styles.presetCustomAddBtn,
2740                      draft.trim().length === 0 && { opacity: 0.4 },
2741                      pressed && modalStyles.actionBtnPressed,
2742                    ]}>
2743                    <Ionicons name="checkmark" size={16} color={Palette.highlight} />
2744                  </Pressable>
2745                </View>
2746                {editError ? (
2747                  <Text style={styles.presetCustomError}>{editError}</Text>
2748                ) : null}
2749              </View>
2750            ) : (
2751              <View style={modalStyles.actionList}>
2752                <Pressable
2753                  onPress={() => setEditing(true)}
2754                  style={({ pressed }) => [
2755                    modalStyles.actionBtnPrimary,
2756                    pressed && modalStyles.actionBtnPressed,
2757                  ]}>
2758                  <Ionicons name="create-outline" size={18} color={Palette.highlight} />
2759                  <Text style={modalStyles.actionBtnPrimaryText}>Edit</Text>
2760                </Pressable>
2761  
2762                {canMoveUp || canMoveDown ? (
2763                  <View style={modalStyles.moveRow}>
2764                    <Pressable
2765                      onPress={() => canMoveUp && onMove(-1)}
2766                      disabled={!canMoveUp}
2767                      style={({ pressed }) => [
2768                        modalStyles.actionBtn,
2769                        modalStyles.moveBtn,
2770                        !canMoveUp && modalStyles.moveBtnDisabled,
2771                        pressed && modalStyles.actionBtnPressed,
2772                      ]}>
2773                      <Ionicons
2774                        name="arrow-up"
2775                        size={18}
2776                        color={canMoveUp ? Palette.accentBright : Palette.textMuted}
2777                      />
2778                      <Text style={modalStyles.actionBtnText}>Move up</Text>
2779                    </Pressable>
2780                    <Pressable
2781                      onPress={() => canMoveDown && onMove(1)}
2782                      disabled={!canMoveDown}
2783                      style={({ pressed }) => [
2784                        modalStyles.actionBtn,
2785                        modalStyles.moveBtn,
2786                        !canMoveDown && modalStyles.moveBtnDisabled,
2787                        pressed && modalStyles.actionBtnPressed,
2788                      ]}>
2789                      <Ionicons
2790                        name="arrow-down"
2791                        size={18}
2792                        color={canMoveDown ? Palette.accentBright : Palette.textMuted}
2793                      />
2794                      <Text style={modalStyles.actionBtnText}>Move down</Text>
2795                    </Pressable>
2796                  </View>
2797                ) : null}
2798  
2799                <Pressable
2800                  onPress={onDelete}
2801                  style={({ pressed }) => [
2802                    modalStyles.actionBtn,
2803                    modalStyles.actionBtnDestructive,
2804                    pressed && modalStyles.actionBtnPressed,
2805                  ]}>
2806                  <Ionicons name="trash-outline" size={18} color="#c87070" />
2807                  <Text style={[modalStyles.actionBtnText, { color: '#c87070' }]}>
2808                    Delete
2809                  </Text>
2810                </Pressable>
2811              </View>
2812            )}
2813  
2814            <BackPill onPress={onClose} label="Close" />
2815          </Pressable>
2816        </Pressable>
2817      </Modal>
2818    );
2819  }
2820  
2821  /**
2822   * Per-group preset list. Opened from PresetsModal when the user taps
2823   * a group's forward-navigation row. Same row chrome as the parent so
2824   * the navigation stack feels continuous.
2825   */
2826  function PresetGroupModal({
2827    group, onClose, onPickUrl,
2828  }: {
2829    group: PresetGroup | null;
2830    onClose: () => void;
2831    onPickUrl: (url: string) => void;
2832  }) {
2833    if (!group) {
2834      return <Modal visible={false} transparent onRequestClose={onClose}><View /></Modal>;
2835    }
2836    return (
2837      <Modal visible transparent animationType="fade" onRequestClose={onClose}>
2838        <Pressable style={modalStyles.backdrop} onPress={onClose}>
2839          <Pressable style={modalStyles.sheet} onPress={() => {}}>
2840            <Text style={modalStyles.title}>{group.label}</Text>
2841            <Text style={modalStyles.subtitle}>
2842              Drop a known URL into the link field
2843            </Text>
2844  
2845            <ScrollView
2846              style={styles.presetsScroll}
2847              contentContainerStyle={styles.presetsScrollContent}
2848              showsVerticalScrollIndicator>
2849              {group.items.map((t) => (
2850                <Pressable
2851                  key={t.url}
2852                  onPress={() => onPickUrl(t.url)}
2853                  style={({ pressed }) => [styles.testsRow, pressed && styles.testsRowPressed]}>
2854                  <Ionicons
2855                    name={t.icon}
2856                    size={16}
2857                    color={Palette.accentBright}
2858                    style={styles.presetIcon}
2859                  />
2860                  <View style={styles.testsRowText}>
2861                    <Text style={styles.testsRowLabel}>{t.label}</Text>
2862                    <Text style={styles.testsRowHint} numberOfLines={1}>
2863                      {t.url}
2864                    </Text>
2865                  </View>
2866                </Pressable>
2867              ))}
2868            </ScrollView>
2869  
2870            <BackPill onPress={onClose} />
2871          </Pressable>
2872        </Pressable>
2873      </Modal>
2874    );
2875  }
2876  
2877  /**
2878   * Read-only reference of every tracking key Warden strips. Sorted
2879   * alphabetically; scrollable in case the list grows. Prefix patterns
2880   * (`utm_`, `pk_`, …) sit at the top because they catch the long tail.
2881   */
2882  function TrackingListModal({
2883    visible, onClose,
2884  }: {
2885    visible: boolean;
2886    onClose: () => void;
2887  }) {
2888    const params = Array.from(TRACKING_PARAMS).sort();
2889    // Bound the ScrollView with a deterministic pixel maxHeight so
2890    // the nested-flex math doesn't matter — when the parent sheet
2891    // sits inside another modal's stack on Android, percent-based
2892    // sizing can collapse silently. ~62% of the viewport leaves
2893    // room for the header, subtitle, and BackPill.
2894    const { height: viewportH } = useWindowDimensions();
2895    const scrollMaxH = Math.max(220, Math.floor(viewportH * 0.62));
2896    return (
2897      <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
2898        <Pressable style={modalStyles.backdrop} onPress={onClose}>
2899          <Pressable style={[modalStyles.sheetTall, settingsStyles.listSheet]} onPress={() => {}}>
2900            <View style={settingsStyles.header}>
2901              <Ionicons name="trash-bin-outline" size={18} color={Palette.highlight} />
2902              <Text style={modalStyles.title}>Stripped parameters</Text>
2903            </View>
2904            <Text style={settingsStyles.listSubtitle}>
2905              Removed from every launched URL.
2906            </Text>
2907  
2908            <ScrollView
2909              style={[settingsStyles.listScroll, { maxHeight: scrollMaxH }]}
2910              contentContainerStyle={settingsStyles.listScrollContent}
2911              persistentScrollbar
2912              showsVerticalScrollIndicator
2913              indicatorStyle="white"
2914              nestedScrollEnabled>
2915              <Text style={settingsStyles.listSectionTitle}>Prefix matches</Text>
2916              <View style={settingsStyles.chipGrid}>
2917                {TRACKING_PREFIXES.map((p) => (
2918                  <View key={p} style={settingsStyles.chip}>
2919                    <Text style={settingsStyles.chipText}>{p}*</Text>
2920                  </View>
2921                ))}
2922              </View>
2923  
2924              <Text style={[settingsStyles.listSectionTitle, { marginTop: 14 }]}>
2925                Exact matches
2926              </Text>
2927              <View style={settingsStyles.chipGrid}>
2928                {params.map((k) => (
2929                  <View key={k} style={settingsStyles.chip}>
2930                    <Text style={settingsStyles.chipText}>{k}</Text>
2931                  </View>
2932                ))}
2933              </View>
2934  
2935              {/* Per-site rules — only fire when the URL's host matches.
2936                  Used for keys that collide with functional params on
2937                  other domains (e.g. `t` is share-tracking on X but a
2938                  video timestamp on YouTube). */}
2939              <Text style={[settingsStyles.listSectionTitle, { marginTop: 14 }]}>
2940                Per-site rules
2941              </Text>
2942              {(() => {
2943                // Group sites that share the same param set — e.g.
2944                // twitter.com and x.com use identical rules, so we render
2945                // them on one host line instead of two near-duplicate
2946                // blocks.
2947                const groups: { hosts: string[]; params: string[] }[] = [];
2948                for (const [host, rule] of SITE_TRACKING_RULES) {
2949                  const params = Array.from(rule.remove).sort();
2950                  const key = params.join(',');
2951                  const existing = groups.find((g) => g.params.join(',') === key);
2952                  if (existing) existing.hosts.push(host);
2953                  else groups.push({ hosts: [host], params });
2954                }
2955                return groups.map((g) => (
2956                  <View key={g.hosts.join(',')} style={settingsStyles.siteBlock}>
2957                    <Text style={settingsStyles.siteHost}>{g.hosts.join(' / ')}</Text>
2958                    <View style={settingsStyles.chipGrid}>
2959                      {g.params.map((k) => (
2960                        <View key={k} style={settingsStyles.chip}>
2961                          <Text style={settingsStyles.chipText}>{k}</Text>
2962                        </View>
2963                      ))}
2964                    </View>
2965                  </View>
2966                ));
2967              })()}
2968            </ScrollView>
2969  
2970            <BackPill onPress={onClose} />
2971          </Pressable>
2972        </Pressable>
2973      </Modal>
2974    );
2975  }
2976  
2977  /**
2978   * Privacy-proxies sub-screen — opens from Settings when "Use privacy
2979   * proxies" is on. Each row is a destination (Twitter / X, …) with a
2980   * chevron leading to a per-destination detail screen where the user
2981   * picks instances + randomise.
2982   */
2983  function ProxiesListModal({
2984    visible, proxyConfig, onSetProxyConfig, onClose,
2985  }: {
2986    visible: boolean;
2987    proxyConfig: ProxyConfig;
2988    onSetProxyConfig: (next: ProxyConfig) => void;
2989    onClose: () => void;
2990  }) {
2991    const [openDest, setOpenDest] = useState<ProxyDestination | null>(null);
2992    const [openCustom, setOpenCustom] = useState<CustomDest | null>(null);
2993    const [addTitle, setAddTitle] = useState('');
2994    const [addingOpen, setAddingOpen] = useState(false);
2995  
2996    const addCustomDest = () => {
2997      const label = addTitle.trim();
2998      if (!label) return;
2999      const fresh: CustomDest = {
3000        id: newCustomDestId(),
3001        label,
3002        matches: [],
3003        instances: [],
3004        randomize: false,
3005        disabled: [],
3006      };
3007      onSetProxyConfig({
3008        ...proxyConfig,
3009        customDests: [...proxyConfig.customDests, fresh],
3010      });
3011      setAddTitle('');
3012      setAddingOpen(false);
3013      setOpenCustom(fresh);
3014    };
3015  
3016    return (
3017      <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
3018        <Pressable style={modalStyles.backdrop} onPress={onClose}>
3019          <Pressable style={[modalStyles.sheet, settingsStyles.sheet]} onPress={() => {}}>
3020            <View style={settingsStyles.header}>
3021              <Ionicons name="shuffle-outline" size={18} color={Palette.highlight} />
3022              <Text style={modalStyles.title}>Privacy proxies</Text>
3023            </View>
3024            <Text style={settingsStyles.listSubtitle}>
3025              Pick which sites get redirected, and tune hostname choice per site.
3026            </Text>
3027  
3028            {PROXY_DESTINATIONS.map((d) => {
3029              const entry = proxyConfig.dests[d.id];
3030              const disabled = new Set(entry?.disabled ?? []);
3031              const total = d.instances.length + (entry?.custom?.length ?? 0);
3032              const enabledCount = enabledInstanceCount(d, proxyConfig);
3033              return (
3034                <Pressable
3035                  key={d.id}
3036                  onPress={() => setOpenDest(d)}
3037                  style={({ pressed }) => [
3038                    settingsStyles.row,
3039                    { marginTop: 8 },
3040                    pressed && modalStyles.actionBtnPressed,
3041                  ]}>
3042                  <View style={settingsStyles.rowText}>
3043                    <Text style={settingsStyles.rowLabel}>{d.label}</Text>
3044                    <Text style={settingsStyles.rowHint}>
3045                      {enabledCount} of {total} hostname{total === 1 ? '' : 's'} enabled
3046                      {enabledCount > 1 ? ' · randomised' : ''}
3047                    </Text>
3048                  </View>
3049                  <Ionicons name="chevron-forward" size={16} color={Palette.accentBright} />
3050                </Pressable>
3051              );
3052            })}
3053  
3054            {proxyConfig.customDests.map((c) => {
3055              const enabled = c.instances.filter((h) => !c.disabled.includes(h)).length;
3056              return (
3057                <Pressable
3058                  key={c.id}
3059                  onPress={() => setOpenCustom(c)}
3060                  style={({ pressed }) => [
3061                    settingsStyles.row,
3062                    { marginTop: 8 },
3063                    pressed && modalStyles.actionBtnPressed,
3064                  ]}>
3065                  <View style={settingsStyles.rowText}>
3066                    <View style={settingsStyles.rowLabelRow}>
3067                      <Text style={settingsStyles.rowLabel}>{c.label}</Text>
3068                      <View style={settingsStyles.customBadge}>
3069                        <Text style={settingsStyles.customBadgeText}>CUSTOM</Text>
3070                      </View>
3071                    </View>
3072                    <Text style={settingsStyles.rowHint}>
3073                      {c.instances.length === 0
3074                        ? 'No hostnames yet — tap to configure'
3075                        : `${enabled} of ${c.instances.length} hostname${c.instances.length === 1 ? '' : 's'} enabled${enabled > 1 ? ' · randomised' : ''}`}
3076                    </Text>
3077                  </View>
3078                  <Ionicons name="chevron-forward" size={16} color={Palette.accentBright} />
3079                </Pressable>
3080              );
3081            })}
3082  
3083            {/* Add custom proxy — inline title prompt expands when tapped. */}
3084            {!addingOpen ? (
3085              <Pressable
3086                onPress={() => setAddingOpen(true)}
3087                style={({ pressed }) => [
3088                  settingsStyles.addCustomDestBtn,
3089                  pressed && modalStyles.actionBtnPressed,
3090                ]}>
3091                <Ionicons name="add" size={14} color={Palette.highlight} />
3092                <Text style={settingsStyles.addCustomDestText}>Add custom proxy</Text>
3093              </Pressable>
3094            ) : (
3095              <View style={settingsStyles.customAddRow}>
3096                <TextInput
3097                  value={addTitle}
3098                  onChangeText={setAddTitle}
3099                  onSubmitEditing={addCustomDest}
3100                  placeholder="Title — e.g. Reddit via Redlib"
3101                  placeholderTextColor={Palette.textMuted}
3102                  autoCapitalize="sentences"
3103                  autoCorrect={false}
3104                  style={settingsStyles.customAddInput}
3105                  autoFocus
3106                />
3107                <Pressable
3108                  onPress={() => { setAddTitle(''); setAddingOpen(false); }}
3109                  hitSlop={6}
3110                  style={({ pressed }) => [
3111                    settingsStyles.customAddBtn,
3112                    { borderColor: Palette.border },
3113                    pressed && modalStyles.actionBtnPressed,
3114                  ]}>
3115                  <Ionicons name="close" size={14} color={Palette.textMuted} />
3116                </Pressable>
3117                <Pressable
3118                  onPress={addCustomDest}
3119                  disabled={addTitle.trim().length === 0}
3120                  style={({ pressed }) => [
3121                    settingsStyles.customAddBtn,
3122                    addTitle.trim().length === 0 && { opacity: 0.4 },
3123                    pressed && modalStyles.actionBtnPressed,
3124                  ]}>
3125                  <Ionicons name="checkmark" size={16} color={Palette.highlight} />
3126                </Pressable>
3127              </View>
3128            )}
3129  
3130            <BackPill onPress={onClose} />
3131          </Pressable>
3132        </Pressable>
3133  
3134        <ProxyDestinationModal
3135          dest={openDest}
3136          proxyConfig={proxyConfig}
3137          onSetProxyConfig={onSetProxyConfig}
3138          onClose={() => setOpenDest(null)}
3139        />
3140  
3141        <CustomProxyDestinationModal
3142          dest={openCustom}
3143          proxyConfig={proxyConfig}
3144          onSetProxyConfig={onSetProxyConfig}
3145          onClose={() => setOpenCustom(null)}
3146        />
3147      </Modal>
3148    );
3149  }
3150  
3151  /**
3152   * Detail screen for a user-defined custom destination. Same chrome as
3153   * the built-in ProxyDestinationModal (randomise + instances list) but
3154   * adds editors for the title, the match hostnames, and a delete
3155   * action — because all of those are user-supplied for a custom dest.
3156   */
3157  function CustomProxyDestinationModal({
3158    dest, proxyConfig, onSetProxyConfig, onClose,
3159  }: {
3160    dest: CustomDest | null;
3161    proxyConfig: ProxyConfig;
3162    onSetProxyConfig: (next: ProxyConfig) => void;
3163    onClose: () => void;
3164  }) {
3165    // Bound the ScrollView with a deterministic pixel cap — `flex: 1`
3166    // inside sheetTall under KeyboardAvoidingView doesn't propagate a
3167    // definite height on Android, so the ScrollView never overflows
3168    // and never scrolls. ~62% of viewport leaves room for the header
3169    // + action row.
3170    const { height: viewportH } = useWindowDimensions();
3171    const scrollMaxH = Math.max(240, Math.floor(viewportH * 0.62));
3172    const [titleDraft, setTitleDraft] = useState('');
3173    const [matchInput, setMatchInput] = useState('');
3174    const [matchError, setMatchError] = useState<string | null>(null);
3175    const [instanceInput, setInstanceInput] = useState('');
3176    const [instanceError, setInstanceError] = useState<string | null>(null);
3177  
3178    useEffect(() => {
3179      if (dest) {
3180        setTitleDraft(dest.label);
3181        setMatchInput('');
3182        setMatchError(null);
3183        setInstanceInput('');
3184        setInstanceError(null);
3185      }
3186    }, [dest?.id]);  // eslint-disable-line react-hooks/exhaustive-deps
3187  
3188    if (!dest) {
3189      return <Modal visible={false} transparent onRequestClose={onClose}><View /></Modal>;
3190    }
3191  
3192    // All mutations: clone the customDests array with one entry patched
3193    // (or removed). Keeps the rest of the config untouched.
3194    const updateThis = (patch: Partial<CustomDest>) => {
3195      onSetProxyConfig({
3196        ...proxyConfig,
3197        customDests: proxyConfig.customDests.map((c) =>
3198          c.id === dest.id ? { ...c, ...patch } : c,
3199        ),
3200      });
3201    };
3202    const removeThis = () => {
3203      onSetProxyConfig({
3204        ...proxyConfig,
3205        customDests: proxyConfig.customDests.filter((c) => c.id !== dest.id),
3206      });
3207      onClose();
3208    };
3209  
3210    const commitTitle = () => {
3211      const next = titleDraft.trim();
3212      if (next && next !== dest.label) updateThis({ label: next });
3213      else setTitleDraft(dest.label);
3214    };
3215  
3216    const addMatch = () => {
3217      const host = normalizeProxyHost(matchInput);
3218      if (!host) {
3219        setMatchError('Enter a valid hostname (e.g. reddit.com)');
3220        return;
3221      }
3222      if (dest.matches.includes(host)) {
3223        setMatchError('Already in the match list');
3224        return;
3225      }
3226      updateThis({ matches: [...dest.matches, host] });
3227      setMatchInput('');
3228      setMatchError(null);
3229    };
3230    const removeMatch = (host: string) => {
3231      updateThis({ matches: dest.matches.filter((h) => h !== host) });
3232    };
3233  
3234    const addInstance = () => {
3235      const host = normalizeProxyHost(instanceInput);
3236      if (!host) {
3237        setInstanceError('Enter a valid hostname (e.g. redlib.example.com)');
3238        return;
3239      }
3240      if (dest.instances.includes(host)) {
3241        setInstanceError('Already in the hostnames list');
3242        return;
3243      }
3244      updateThis({ instances: [...dest.instances, host] });
3245      setInstanceInput('');
3246      setInstanceError(null);
3247    };
3248    const removeInstance = (host: string) => {
3249      updateThis({
3250        instances: dest.instances.filter((h) => h !== host),
3251        disabled: dest.disabled.filter((h) => h !== host),
3252      });
3253    };
3254    const toggleInstance = (host: string) => {
3255      const isDisabled = dest.disabled.includes(host);
3256      updateThis({
3257        disabled: isDisabled
3258          ? dest.disabled.filter((h) => h !== host)
3259          : [...dest.disabled, host],
3260      });
3261    };
3262  
3263    return (
3264      <Modal visible transparent animationType="fade" onRequestClose={onClose}>
3265        <Pressable style={modalStyles.backdrop} onPress={onClose}>
3266          <KeyboardAvoidingView
3267            behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
3268            style={modalStyles.keyboardWrap}
3269            pointerEvents="box-none">
3270          <Pressable style={[modalStyles.sheetTall, settingsStyles.sheet]} onPress={() => {}}>
3271            <View style={settingsStyles.header}>
3272              <Ionicons name="construct-outline" size={18} color={Palette.highlight} />
3273              <Text style={modalStyles.title}>{dest.label || 'Custom proxy'}</Text>
3274            </View>
3275  
3276            <ScrollView
3277              style={[settingsStyles.customDestScroll, { maxHeight: scrollMaxH }]}
3278              contentContainerStyle={{ paddingVertical: 4 }}
3279              keyboardShouldPersistTaps="handled"
3280              nestedScrollEnabled>
3281              {/* Title editor — commit on blur or submit. */}
3282              <Text style={settingsStyles.listSectionTitle}>Title</Text>
3283              <View style={settingsStyles.fieldRow}>
3284                <TextInput
3285                  value={titleDraft}
3286                  onChangeText={setTitleDraft}
3287                  onBlur={commitTitle}
3288                  onSubmitEditing={commitTitle}
3289                  placeholder="Title"
3290                  placeholderTextColor={Palette.textMuted}
3291                  autoCapitalize="sentences"
3292                  autoCorrect={false}
3293                  style={settingsStyles.fieldInput}
3294                />
3295              </View>
3296  
3297              {/* Match hosts — which sites this custom proxy intercepts. */}
3298              <Text style={[settingsStyles.listSectionTitle, { marginTop: 14 }]}>
3299                Match hosts
3300              </Text>
3301              {dest.matches.length === 0 ? (
3302                <Text style={settingsStyles.emptyNote}>
3303                  No hosts yet — add one below.
3304                </Text>
3305              ) : (
3306                dest.matches.map((host) => (
3307                  <View key={host} style={settingsStyles.instanceRow}>
3308                    <Text style={settingsStyles.instanceHost} numberOfLines={1}>
3309                      {host}
3310                    </Text>
3311                    <Pressable
3312                      onPress={() => removeMatch(host)}
3313                      hitSlop={6}
3314                      style={({ pressed }) => [
3315                        settingsStyles.instanceRemove,
3316                        pressed && modalStyles.actionBtnPressed,
3317                      ]}>
3318                      <Ionicons name="close" size={14} color={Palette.textMuted} />
3319                    </Pressable>
3320                  </View>
3321                ))
3322              )}
3323              <View style={settingsStyles.customAddRow}>
3324                <TextInput
3325                  value={matchInput}
3326                  onChangeText={(t) => { setMatchInput(t); if (matchError) setMatchError(null); }}
3327                  onSubmitEditing={addMatch}
3328                  placeholder="Match host — e.g. reddit.com"
3329                  placeholderTextColor={Palette.textMuted}
3330                  autoCapitalize="none"
3331                  autoCorrect={false}
3332                  keyboardType="url"
3333                  style={settingsStyles.customAddInput}
3334                />
3335                <Pressable
3336                  onPress={addMatch}
3337                  disabled={matchInput.trim().length === 0}
3338                  style={({ pressed }) => [
3339                    settingsStyles.customAddBtn,
3340                    matchInput.trim().length === 0 && { opacity: 0.4 },
3341                    pressed && modalStyles.actionBtnPressed,
3342                  ]}>
3343                  <Ionicons name="add" size={16} color={Palette.highlight} />
3344                </Pressable>
3345              </View>
3346              {matchError ? (
3347                <Text style={settingsStyles.customAddError}>{matchError}</Text>
3348              ) : null}
3349  
3350              {/* Instances — picked at random when more than one is
3351                  enabled. Cycle button on the URL status row steps
3352                  forward through the enabled rotation. */}
3353              <Text style={[settingsStyles.listSectionTitle, { marginTop: 14, marginBottom: 6 }]}>
3354                Hostnames
3355              </Text>
3356              <Text style={settingsStyles.emptyNote}>
3357                Picked at random when more than one is enabled.
3358              </Text>
3359              {dest.instances.length === 0 ? (
3360                <Text style={settingsStyles.emptyNote}>
3361                  No hostnames yet — add one below.
3362                </Text>
3363              ) : (
3364                dest.instances.map((host) => {
3365                  const isOn = !dest.disabled.includes(host);
3366                  return (
3367                    <Pressable
3368                      key={host}
3369                      onPress={() => toggleInstance(host)}
3370                      style={({ pressed }) => [
3371                        settingsStyles.instanceRow,
3372                        pressed && modalStyles.actionBtnPressed,
3373                      ]}>
3374                      <Text style={settingsStyles.instanceHost} numberOfLines={1}>
3375                        {host}
3376                      </Text>
3377                      <Pressable
3378                        onPress={() => removeInstance(host)}
3379                        hitSlop={6}
3380                        style={({ pressed }) => [
3381                          settingsStyles.instanceRemove,
3382                          pressed && modalStyles.actionBtnPressed,
3383                        ]}>
3384                        <Ionicons name="close" size={14} color={Palette.textMuted} />
3385                      </Pressable>
3386                      <View
3387                        style={[
3388                          editorStyles.switchTrack,
3389                          isOn && editorStyles.switchTrackOn,
3390                        ]}>
3391                        <View
3392                          style={[
3393                            editorStyles.switchThumb,
3394                            isOn && editorStyles.switchThumbOn,
3395                          ]}
3396                        />
3397                      </View>
3398                    </Pressable>
3399                  );
3400                })
3401              )}
3402              <View style={settingsStyles.customAddRow}>
3403                <TextInput
3404                  value={instanceInput}
3405                  onChangeText={(t) => { setInstanceInput(t); if (instanceError) setInstanceError(null); }}
3406                  onSubmitEditing={addInstance}
3407                  placeholder="Custom hostname"
3408                  placeholderTextColor={Palette.textMuted}
3409                  autoCapitalize="none"
3410                  autoCorrect={false}
3411                  keyboardType="url"
3412                  style={settingsStyles.customAddInput}
3413                />
3414                <Pressable
3415                  onPress={addInstance}
3416                  disabled={instanceInput.trim().length === 0}
3417                  style={({ pressed }) => [
3418                    settingsStyles.customAddBtn,
3419                    instanceInput.trim().length === 0 && { opacity: 0.4 },
3420                    pressed && modalStyles.actionBtnPressed,
3421                  ]}>
3422                  <Ionicons name="add" size={16} color={Palette.highlight} />
3423                </Pressable>
3424              </View>
3425              {instanceError ? (
3426                <Text style={settingsStyles.customAddError}>{instanceError}</Text>
3427              ) : null}
3428  
3429              {/* Delete this custom destination — destructive, separated. */}
3430              <Pressable
3431                onPress={removeThis}
3432                style={({ pressed }) => [
3433                  settingsStyles.deleteDestBtn,
3434                  pressed && modalStyles.actionBtnPressed,
3435                ]}>
3436                <Ionicons name="trash-outline" size={14} color="#c87070" />
3437                <Text style={settingsStyles.deleteDestText}>Delete this proxy</Text>
3438              </Pressable>
3439            </ScrollView>
3440  
3441            <BackPill onPress={onClose} />
3442          </Pressable>
3443          </KeyboardAvoidingView>
3444        </Pressable>
3445      </Modal>
3446    );
3447  }
3448  
3449  /**
3450   * Per-destination detail screen — randomise toggle on top, each
3451   * instance below with its own on/off. Driven from the parent's
3452   * proxyConfig + onSetProxyConfig so changes persist immediately.
3453   */
3454  function ProxyDestinationModal({
3455    dest, proxyConfig, onSetProxyConfig, onClose,
3456  }: {
3457    dest: ProxyDestination | null;
3458    proxyConfig: ProxyConfig;
3459    onSetProxyConfig: (next: ProxyConfig) => void;
3460    onClose: () => void;
3461  }) {
3462    const [addInput, setAddInput] = useState('');
3463    const [addError, setAddError] = useState<string | null>(null);
3464    if (!dest) {
3465      return <Modal visible={false} transparent onRequestClose={onClose}><View /></Modal>;
3466    }
3467    const entry = proxyConfig.dests[dest.id] ?? { randomize: false, disabled: [], custom: [] };
3468    const disabled = new Set(entry.disabled);
3469    const customHosts = new Set(entry.custom);
3470    const allInstances = allInstancesFor(dest, proxyConfig);
3471  
3472    const toggleInstance = (host: string) => {
3473      const nextDisabled = disabled.has(host)
3474        ? entry.disabled.filter((h) => h !== host)
3475        : [...entry.disabled, host];
3476      onSetProxyConfig({
3477        ...proxyConfig,
3478        dests: { ...proxyConfig.dests, [dest.id]: { ...entry, disabled: nextDisabled } },
3479      });
3480    };
3481    const addCustom = () => {
3482      const host = normalizeProxyHost(addInput);
3483      if (!host) {
3484        setAddError('Enter a valid hostname (e.g. nitter.example.com)');
3485        return;
3486      }
3487      if (allInstances.some((i) => i.host === host)) {
3488        setAddError('That hostname is already in the list');
3489        return;
3490      }
3491      onSetProxyConfig({
3492        ...proxyConfig,
3493        dests: {
3494          ...proxyConfig.dests,
3495          [dest.id]: { ...entry, custom: [...entry.custom, host] },
3496        },
3497      });
3498      setAddInput('');
3499      setAddError(null);
3500    };
3501    const removeCustom = (host: string) => {
3502      onSetProxyConfig({
3503        ...proxyConfig,
3504        dests: {
3505          ...proxyConfig.dests,
3506          [dest.id]: {
3507            ...entry,
3508            custom: entry.custom.filter((h) => h !== host),
3509            // Also drop from disabled so we don't leak orphaned entries.
3510            disabled: entry.disabled.filter((h) => h !== host),
3511          },
3512        },
3513      });
3514    };
3515  
3516    return (
3517      <Modal visible transparent animationType="fade" onRequestClose={onClose}>
3518        <Pressable style={modalStyles.backdrop} onPress={onClose}>
3519          <KeyboardAvoidingView
3520            behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
3521            style={modalStyles.keyboardWrap}
3522            pointerEvents="box-none">
3523          <Pressable style={[modalStyles.sheet, settingsStyles.sheet]} onPress={() => {}}>
3524            <View style={settingsStyles.header}>
3525              <Ionicons name="shuffle-outline" size={18} color={Palette.highlight} />
3526              <Text style={modalStyles.title}>{dest.label}</Text>
3527            </View>
3528  
3529            <Text style={[settingsStyles.listSectionTitle, { marginBottom: 6 }]}>
3530              Hostnames
3531            </Text>
3532            <Text style={settingsStyles.emptyNote}>
3533              Picked at random when more than one is enabled. Use the cycle
3534              button on the URL status row to step forward through them.
3535            </Text>
3536  
3537            {allInstances.map((inst) => {
3538              const isOn = !disabled.has(inst.host);
3539              const isCustom = customHosts.has(inst.host);
3540              return (
3541                <Pressable
3542                  key={inst.host}
3543                  onPress={() => toggleInstance(inst.host)}
3544                  style={({ pressed }) => [
3545                    settingsStyles.instanceRow,
3546                    pressed && modalStyles.actionBtnPressed,
3547                  ]}>
3548                  <Text style={settingsStyles.instanceHost} numberOfLines={1}>
3549                    {inst.host}
3550                  </Text>
3551                  {isCustom ? (
3552                    <Pressable
3553                      onPress={() => removeCustom(inst.host)}
3554                      hitSlop={6}
3555                      accessibilityLabel="Remove custom instance"
3556                      style={({ pressed }) => [
3557                        settingsStyles.instanceRemove,
3558                        pressed && modalStyles.actionBtnPressed,
3559                      ]}>
3560                      <Ionicons
3561                        name="close"
3562                        size={14}
3563                        color={Palette.textMuted}
3564                      />
3565                    </Pressable>
3566                  ) : null}
3567                  <View
3568                    style={[
3569                      editorStyles.switchTrack,
3570                      isOn && editorStyles.switchTrackOn,
3571                    ]}>
3572                    <View
3573                      style={[
3574                        editorStyles.switchThumb,
3575                        isOn && editorStyles.switchThumbOn,
3576                      ]}
3577                    />
3578                  </View>
3579                </Pressable>
3580              );
3581            })}
3582  
3583            {/* Add a custom instance — inline input + Add button. Sits
3584                below the existing list so the user can type a hostname
3585                like `nitter.private.coffee` and add it to the rotation
3586                alongside the defaults. */}
3587            <View style={settingsStyles.customAddRow}>
3588              <TextInput
3589                value={addInput}
3590                onChangeText={(t) => { setAddInput(t); if (addError) setAddError(null); }}
3591                onSubmitEditing={addCustom}
3592                placeholder="Custom instance"
3593                placeholderTextColor={Palette.textMuted}
3594                autoCapitalize="none"
3595                autoCorrect={false}
3596                keyboardType="url"
3597                style={settingsStyles.customAddInput}
3598              />
3599              <Pressable
3600                onPress={addCustom}
3601                disabled={addInput.trim().length === 0}
3602                style={({ pressed }) => [
3603                  settingsStyles.customAddBtn,
3604                  addInput.trim().length === 0 && { opacity: 0.4 },
3605                  pressed && modalStyles.actionBtnPressed,
3606                ]}>
3607                <Ionicons name="add" size={16} color={Palette.highlight} />
3608              </Pressable>
3609            </View>
3610            {addError ? (
3611              <Text style={settingsStyles.customAddError}>{addError}</Text>
3612            ) : null}
3613  
3614            <BackPill onPress={onClose} />
3615          </Pressable>
3616          </KeyboardAvoidingView>
3617        </Pressable>
3618      </Modal>
3619    );
3620  }
3621  
3622  /**
3623   * Lightweight settings sheet. Today it carries a single toggle for
3624   * stripping tracking parameters from URLs (Brave-equivalent default).
3625   * Designed to grow — drop more rows in here as needs come up.
3626   */
3627  function SettingsModal({
3628    visible, stripTracking, onSetStripTracking,
3629    runicNames, onSetRunicNames,
3630    showPaste, onSetShowPaste,
3631    showClear, onSetShowClear,
3632    proxyConfig, onSetProxyConfig,
3633    onClose,
3634  }: {
3635    visible: boolean;
3636    stripTracking: boolean;
3637    onSetStripTracking: (next: boolean) => void;
3638    runicNames: boolean;
3639    onSetRunicNames: (next: boolean) => void;
3640    showPaste: boolean;
3641    onSetShowPaste: (next: boolean) => void;
3642    showClear: boolean;
3643    onSetShowClear: (next: boolean) => void;
3644    proxyConfig: ProxyConfig;
3645    onSetProxyConfig: (next: ProxyConfig) => void;
3646    onClose: () => void;
3647  }) {
3648    const [trackingListOpen, setTrackingListOpen] = useState(false);
3649    const [proxiesOpen, setProxiesOpen] = useState(false);
3650    const [activeTab, setActiveTab] = useState<'privacy' | 'ui'>('privacy');
3651    return (
3652      <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
3653        <Pressable style={modalStyles.backdrop} onPress={onClose}>
3654          <Pressable style={[modalStyles.sheet, settingsStyles.sheet]} onPress={() => {}}>
3655            <View style={settingsStyles.header}>
3656              <Ionicons name="settings-outline" size={18} color={Palette.highlight} />
3657              <Text style={modalStyles.title}>Settings</Text>
3658            </View>
3659  
3660            {/* Tab strip — Privacy (strip-tracking, strip-referrer,
3661                privacy proxies) vs UI (display flourishes like the
3662                runic browser names). Pure visual grouping; settings
3663                are still shared one StyleSheet. */}
3664            <View style={settingsStyles.tabStrip}>
3665              <Pressable
3666                onPress={() => setActiveTab('privacy')}
3667                style={({ pressed }) => [
3668                  settingsStyles.tab,
3669                  activeTab === 'privacy' && settingsStyles.tabActive,
3670                  pressed && modalStyles.actionBtnPressed,
3671                ]}>
3672                <Text
3673                  style={[
3674                    settingsStyles.tabLabel,
3675                    activeTab === 'privacy' && settingsStyles.tabLabelActive,
3676                  ]}>
3677                  Privacy
3678                </Text>
3679              </Pressable>
3680              <Pressable
3681                onPress={() => setActiveTab('ui')}
3682                style={({ pressed }) => [
3683                  settingsStyles.tab,
3684                  activeTab === 'ui' && settingsStyles.tabActive,
3685                  pressed && modalStyles.actionBtnPressed,
3686                ]}>
3687                <Text
3688                  style={[
3689                    settingsStyles.tabLabel,
3690                    activeTab === 'ui' && settingsStyles.tabLabelActive,
3691                  ]}>
3692                  UI
3693                </Text>
3694              </Pressable>
3695            </View>
3696  
3697            {activeTab === 'privacy' ? <>
3698            {/* Row split into two distinct tap zones — text body (left
3699                of the rowSep) opens the info modal; switch zone (sep
3700                + track, right of the rowSep) toggles. The outer
3701                container is a plain View, so tapping anywhere
3702                hits one of the two zones cleanly. */}
3703            <View style={settingsStyles.row}>
3704              <Pressable
3705                onPress={() => setTrackingListOpen(true)}
3706                style={({ pressed }) => [
3707                  settingsStyles.rowBodyZone,
3708                  settingsStyles.rowBodyZoneRow,
3709                  pressed && modalStyles.actionBtnPressed,
3710                ]}>
3711                <View style={{ flex: 1, gap: 3 }}>
3712                  <Text style={settingsStyles.rowLabel}>Remove Tracking Params</Text>
3713                  <Text style={settingsStyles.rowHint}>
3714                    Strip tracking parameters from launched URLs
3715                  </Text>
3716                </View>
3717                <Ionicons
3718                  name="chevron-forward"
3719                  size={16}
3720                  color={Palette.accentBright}
3721                />
3722              </Pressable>
3723              <Pressable
3724                onPress={() => onSetStripTracking(!stripTracking)}
3725                hitSlop={6}
3726                style={({ pressed }) => [
3727                  settingsStyles.rowSwitchZone,
3728                  pressed && modalStyles.actionBtnPressed,
3729                ]}>
3730                <View style={settingsStyles.rowSep} />
3731                <View
3732                  style={[
3733                    editorStyles.switchTrack,
3734                    stripTracking && editorStyles.switchTrackOn,
3735                  ]}>
3736                  <View
3737                    style={[
3738                      editorStyles.switchThumb,
3739                      stripTracking && editorStyles.switchThumbOn,
3740                    ]}
3741                  />
3742                </View>
3743              </Pressable>
3744            </View>
3745  
3746            <TrackingListModal
3747              visible={trackingListOpen}
3748              onClose={() => setTrackingListOpen(false)}
3749            />
3750  
3751  
3752            {/* Use proxies — split row: body opens the destinations
3753                list, switch zone toggles the master. Uniform with the
3754                Tracking row above. */}
3755            <View style={[settingsStyles.row, { marginTop: 10 }]}>
3756              <Pressable
3757                onPress={() => setProxiesOpen(true)}
3758                style={({ pressed }) => [
3759                  settingsStyles.rowBodyZone,
3760                  settingsStyles.rowBodyZoneRow,
3761                  pressed && modalStyles.actionBtnPressed,
3762                ]}>
3763                <View style={{ flex: 1, gap: 3 }}>
3764                  <Text style={settingsStyles.rowLabel}>Use proxies</Text>
3765                  <Text style={settingsStyles.rowHint}>
3766                    Redirect sites to privacy-respecting proxies
3767                  </Text>
3768                </View>
3769                <Ionicons
3770                  name="chevron-forward"
3771                  size={16}
3772                  color={Palette.accentBright}
3773                />
3774              </Pressable>
3775              <Pressable
3776                onPress={() =>
3777                  onSetProxyConfig({ ...proxyConfig, enabled: !proxyConfig.enabled })
3778                }
3779                hitSlop={6}
3780                style={({ pressed }) => [
3781                  settingsStyles.rowSwitchZone,
3782                  pressed && modalStyles.actionBtnPressed,
3783                ]}>
3784                <View style={settingsStyles.rowSep} />
3785                <View
3786                  style={[
3787                    editorStyles.switchTrack,
3788                    proxyConfig.enabled && editorStyles.switchTrackOn,
3789                  ]}>
3790                  <View
3791                    style={[
3792                      editorStyles.switchThumb,
3793                      proxyConfig.enabled && editorStyles.switchThumbOn,
3794                    ]}
3795                  />
3796                </View>
3797              </Pressable>
3798            </View>
3799  
3800            <ProxiesListModal
3801              visible={proxiesOpen}
3802              proxyConfig={proxyConfig}
3803              onSetProxyConfig={onSetProxyConfig}
3804              onClose={() => setProxiesOpen(false)}
3805            />
3806            </> : null}
3807  
3808            {activeTab === 'ui' ? <>
3809            {/* Runic browser names. No detail screen → body taps fall
3810                through to the same toggle as the switch zone. */}
3811            <View style={settingsStyles.row}>
3812              <Pressable
3813                onPress={() => onSetRunicNames(!runicNames)}
3814                style={({ pressed }) => [
3815                  settingsStyles.rowBodyZone,
3816                  pressed && modalStyles.actionBtnPressed,
3817                ]}>
3818                <Text style={settingsStyles.rowLabel}>Runic browser names</Text>
3819                <Text style={settingsStyles.rowHint}>
3820                  Render browser names in Elder Futhark runes
3821                </Text>
3822              </Pressable>
3823              <Pressable
3824                onPress={() => onSetRunicNames(!runicNames)}
3825                hitSlop={6}
3826                style={({ pressed }) => [
3827                  settingsStyles.rowSwitchZone,
3828                  pressed && modalStyles.actionBtnPressed,
3829                ]}>
3830                <View style={settingsStyles.rowSep} />
3831                <View
3832                  style={[
3833                    editorStyles.switchTrack,
3834                    runicNames && editorStyles.switchTrackOn,
3835                  ]}>
3836                  <View
3837                    style={[
3838                      editorStyles.switchThumb,
3839                      runicNames && editorStyles.switchThumbOn,
3840                    ]}
3841                  />
3842                </View>
3843              </Pressable>
3844            </View>
3845  
3846            {/* Show Paste. No detail screen → body toggles. */}
3847            <View style={[settingsStyles.row, { marginTop: 10 }]}>
3848              <Pressable
3849                onPress={() => onSetShowPaste(!showPaste)}
3850                style={({ pressed }) => [
3851                  settingsStyles.rowBodyZone,
3852                  pressed && modalStyles.actionBtnPressed,
3853                ]}>
3854                <Text style={settingsStyles.rowLabel}>Show Paste</Text>
3855                <Text style={settingsStyles.rowHint}>
3856                  Show the Paste pill in the SOURCE tools row
3857                </Text>
3858              </Pressable>
3859              <Pressable
3860                onPress={() => onSetShowPaste(!showPaste)}
3861                hitSlop={6}
3862                style={({ pressed }) => [
3863                  settingsStyles.rowSwitchZone,
3864                  pressed && modalStyles.actionBtnPressed,
3865                ]}>
3866                <View style={settingsStyles.rowSep} />
3867                <View
3868                  style={[
3869                    editorStyles.switchTrack,
3870                    showPaste && editorStyles.switchTrackOn,
3871                  ]}>
3872                  <View
3873                    style={[
3874                      editorStyles.switchThumb,
3875                      showPaste && editorStyles.switchThumbOn,
3876                    ]}
3877                  />
3878                </View>
3879              </Pressable>
3880            </View>
3881  
3882            {/* Show Clear. No detail screen → body toggles. */}
3883            <View style={[settingsStyles.row, { marginTop: 10 }]}>
3884              <Pressable
3885                onPress={() => onSetShowClear(!showClear)}
3886                style={({ pressed }) => [
3887                  settingsStyles.rowBodyZone,
3888                  pressed && modalStyles.actionBtnPressed,
3889                ]}>
3890                <Text style={settingsStyles.rowLabel}>Show Clear</Text>
3891                <Text style={settingsStyles.rowHint}>
3892                  Show the Clear pill in the SOURCE tools row
3893                </Text>
3894              </Pressable>
3895              <Pressable
3896                onPress={() => onSetShowClear(!showClear)}
3897                hitSlop={6}
3898                style={({ pressed }) => [
3899                  settingsStyles.rowSwitchZone,
3900                  pressed && modalStyles.actionBtnPressed,
3901                ]}>
3902                <View style={settingsStyles.rowSep} />
3903                <View
3904                  style={[
3905                    editorStyles.switchTrack,
3906                    showClear && editorStyles.switchTrackOn,
3907                  ]}>
3908                  <View
3909                    style={[
3910                      editorStyles.switchThumb,
3911                      showClear && editorStyles.switchThumbOn,
3912                    ]}
3913                  />
3914                </View>
3915              </Pressable>
3916            </View>
3917            </> : null}
3918  
3919            <View style={modalStyles.explainCloseRow}>
3920              <View style={modalStyles.cycleBtnSpacer} />
3921              <Pressable
3922                onPress={onClose}
3923                style={({ pressed }) => [
3924                  editorStyles.cancelBtn,
3925                  pressed && modalStyles.actionBtnPressed,
3926                ]}>
3927                <Text style={modalStyles.closeBtnText}>Close</Text>
3928              </Pressable>
3929              <View style={modalStyles.cycleBtnSpacer} />
3930            </View>
3931          </Pressable>
3932        </Pressable>
3933      </Modal>
3934    );
3935  }
3936  
3937  const settingsStyles = StyleSheet.create({
3938    sheet: { maxWidth: 380 },
3939    header: {
3940      flexDirection: 'row',
3941      alignItems: 'center',
3942      justifyContent: 'center',
3943      gap: 8,
3944      marginBottom: 12,
3945    },
3946    // Two-up tab strip — Privacy | UI. Active tab gets a filled
3947    // surface + bright text; inactive tabs read as bordered pills.
3948    tabStrip: {
3949      flexDirection: 'row',
3950      gap: 6,
3951      marginBottom: 12,
3952    },
3953    tab: {
3954      flex: 1,
3955      paddingVertical: 8,
3956      paddingHorizontal: 12,
3957      borderRadius: 8,
3958      borderWidth: 1,
3959      borderColor: Palette.border,
3960      alignItems: 'center',
3961      backgroundColor: Palette.bgElevated,
3962    },
3963    tabActive: {
3964      borderColor: Palette.accentBright,
3965      backgroundColor: 'rgba(143,184,122,0.10)',
3966    },
3967    tabLabel: {
3968      fontSize: 12,
3969      fontWeight: '600',
3970      color: Palette.textMuted,
3971      letterSpacing: 0.3,
3972    },
3973    tabLabelActive: {
3974      color: Palette.highlight,
3975    },
3976    row: {
3977      flexDirection: 'row',
3978      alignItems: 'center',
3979      gap: 12,
3980      paddingVertical: 11,
3981      paddingHorizontal: 12,
3982      borderRadius: 11,
3983      borderWidth: 1,
3984      borderColor: Palette.border,
3985      backgroundColor: Palette.bgElevated,
3986    },
3987    rowText: { flex: 1, gap: 3 },
3988    // Tap-zones for the split row pattern (Remove Tracking Params):
3989    // body zone on the left opens the info modal; switch zone on
3990    // the right (separator + track) toggles. Each zone claims its
3991    // own padding so the tap targets stretch to the full row height
3992    // and there's no dead space that falls through to the wrong
3993    // handler.
3994    rowBodyZone: {
3995      flex: 1,
3996      gap: 3,
3997      paddingVertical: 4,
3998      paddingRight: 4,
3999    },
4000    // Variant used by rows whose body opens a detail screen — adds
4001    // a trailing chevron so the affordance reads as "tap for more"
4002    // rather than just static text.
4003    rowBodyZoneRow: {
4004      flexDirection: 'row',
4005      alignItems: 'center',
4006      gap: 8,
4007    },
4008    rowSwitchZone: {
4009      flexDirection: 'row',
4010      alignItems: 'center',
4011      paddingVertical: 4,
4012    },
4013    // Always-visible vertical divider between the label area and the
4014    // toggle / chevron. Makes the boundary explicit so the rightmost
4015    // control reads as a distinct affordance, not part of the row's
4016    // text column.
4017    rowSep: {
4018      width: 1,
4019      // Explicit height (not stretch) so the divider extends past
4020      // the natural row content — reads as a deliberate column rule
4021      // between the body and switch zones rather than a hairline
4022      // that hugs the text.
4023      height: 40,
4024      alignSelf: 'center',
4025      backgroundColor: Palette.border,
4026      marginHorizontal: 16,
4027    },
4028    // Label + info-icon line inside the row text column.
4029    rowLabelRow: {
4030      flexDirection: 'row',
4031      alignItems: 'center',
4032      gap: 6,
4033    },
4034    rowLabel: {
4035      fontSize: 13,
4036      color: Palette.text,
4037      fontWeight: '700',
4038      letterSpacing: 0.2,
4039    },
4040    rowInfo: {
4041      width: 22,
4042      height: 22,
4043      alignItems: 'center',
4044      justifyContent: 'center',
4045      borderRadius: 11,
4046    },
4047    rowHint: {
4048      fontSize: 11,
4049      color: Palette.textMuted,
4050      lineHeight: 15,
4051    },
4052    // ─── Tracking-list modal ───────────────────────────────────────────
4053    listSheet: { maxWidth: 380 },
4054    listSubtitle: {
4055      fontSize: 12,
4056      color: Palette.textMuted,
4057      lineHeight: 17,
4058      marginBottom: 10,
4059    },
4060    listScroll: {
4061      // No flex — when sheetTall has no leftover slack (its other
4062      // children + padding already fit within maxHeight 88%), a
4063      // flex:1 ScrollView resolves to 0 height and the chip rows
4064      // vanish. The inline maxHeight prop applied at the call site
4065      // does the bounding instead; the ScrollView sizes to its
4066      // natural content, capped at that pixel ceiling.
4067      borderTopWidth: 1,
4068      borderBottomWidth: 1,
4069      borderColor: Palette.border,
4070      backgroundColor: Palette.bg,
4071    },
4072    listScrollContent: {
4073      paddingVertical: 12,
4074      paddingHorizontal: 12,
4075    },
4076    listSectionTitle: {
4077      fontSize: 10,
4078      color: Palette.accentBright,
4079      fontWeight: '700',
4080      letterSpacing: 1,
4081      textTransform: 'uppercase',
4082      marginBottom: 6,
4083    },
4084    chipGrid: {
4085      flexDirection: 'row',
4086      flexWrap: 'wrap',
4087      gap: 4,
4088    },
4089    chip: {
4090      paddingHorizontal: 6,
4091      paddingVertical: 2,
4092      borderRadius: 5,
4093      borderWidth: 1,
4094      borderColor: Palette.border,
4095      backgroundColor: Palette.bgElevated,
4096    },
4097    chipText: {
4098      fontSize: 10,
4099      color: Palette.text,
4100      fontFamily: Fonts?.mono,
4101    },
4102    // Per-site block — hostname header sits above its chip grid.
4103    siteBlock: { marginTop: 8 },
4104    siteHost: {
4105      fontSize: 11,
4106      color: Palette.accentBright,
4107      fontFamily: Fonts?.mono,
4108      fontWeight: '600',
4109      marginBottom: 5,
4110    },
4111    // Chevron tucked between the row text and the switch, used by rows
4112    // that lead to a deeper config screen but also carry a toggle.
4113    rowChevron: {
4114      width: 24,
4115      height: 24,
4116      alignItems: 'center',
4117      justifyContent: 'center',
4118      borderRadius: 12,
4119    },
4120    // Compact row used for each proxy instance in the destination
4121    // detail screen. Same border/bg as the main `row` but tighter
4122    // padding so a list of 5–10 instances stays readable.
4123    instanceRow: {
4124      flexDirection: 'row',
4125      alignItems: 'center',
4126      justifyContent: 'space-between',
4127      gap: 10,
4128      paddingVertical: 9,
4129      paddingHorizontal: 12,
4130      borderRadius: 9,
4131      borderWidth: 1,
4132      borderColor: Palette.border,
4133      backgroundColor: Palette.bgElevated,
4134      marginTop: 6,
4135    },
4136    instanceHost: {
4137      flex: 1,
4138      fontSize: 12,
4139      color: Palette.text,
4140      fontFamily: Fonts?.mono,
4141    },
4142    // Small × button rendered between the host text and the switch for
4143    // user-added instances only — built-in defaults can't be deleted,
4144    // only toggled off.
4145    instanceRemove: {
4146      width: 22,
4147      height: 22,
4148      alignItems: 'center',
4149      justifyContent: 'center',
4150      borderRadius: 11,
4151      marginRight: 4,
4152    },
4153    // Inline "add custom" composer at the bottom of the instances list.
4154    customAddRow: {
4155      flexDirection: 'row',
4156      alignItems: 'center',
4157      gap: 8,
4158      marginTop: 10,
4159      paddingVertical: 7,
4160      paddingLeft: 12,
4161      paddingRight: 6,
4162      borderRadius: 9,
4163      borderWidth: 1,
4164      borderStyle: 'dashed',
4165      borderColor: Palette.border,
4166      backgroundColor: Palette.bg,
4167    },
4168    customAddInput: {
4169      flex: 1,
4170      fontSize: 12,
4171      color: Palette.text,
4172      fontFamily: Fonts?.mono,
4173      paddingVertical: 0,
4174      paddingHorizontal: 0,
4175    },
4176    customAddBtn: {
4177      width: 28,
4178      height: 28,
4179      alignItems: 'center',
4180      justifyContent: 'center',
4181      borderRadius: 14,
4182      borderWidth: 1,
4183      borderColor: Palette.highlight,
4184      backgroundColor: Palette.bgElevated,
4185    },
4186    customAddError: {
4187      fontSize: 11,
4188      color: '#d4a04a',
4189      fontStyle: 'italic',
4190      marginTop: 6,
4191      paddingHorizontal: 4,
4192    },
4193    // ─── Custom destination chrome ─────────────────────────────────────
4194    customBadge: {
4195      paddingHorizontal: 6,
4196      paddingVertical: 1,
4197      borderRadius: 6,
4198      borderWidth: 1,
4199      borderColor: Palette.border,
4200      backgroundColor: Palette.bg,
4201    },
4202    customBadgeText: {
4203      fontSize: 8,
4204      color: Palette.accentBright,
4205      fontWeight: '700',
4206      letterSpacing: 0.6,
4207    },
4208    addCustomDestBtn: {
4209      flexDirection: 'row',
4210      alignItems: 'center',
4211      justifyContent: 'center',
4212      gap: 6,
4213      paddingVertical: 11,
4214      paddingHorizontal: 12,
4215      marginTop: 10,
4216      borderRadius: 11,
4217      borderWidth: 1,
4218      borderStyle: 'dashed',
4219      borderColor: Palette.highlight,
4220      backgroundColor: Palette.bg,
4221    },
4222    addCustomDestText: {
4223      fontSize: 12,
4224      color: Palette.highlight,
4225      fontWeight: '700',
4226      letterSpacing: 0.3,
4227    },
4228    customDestScroll: {
4229      // flex:1 so the ScrollView claims the leftover vertical space
4230      // inside the bounded sheetTall — without it, content past the
4231      // sheet's maxHeight gets clipped but never becomes scrollable.
4232      flex: 1,
4233    },
4234    // Generic "form field" row inside the custom-destination editor —
4235    // borders + bg match the per-instance rows for visual consistency.
4236    fieldRow: {
4237      paddingVertical: 9,
4238      paddingHorizontal: 12,
4239      borderRadius: 9,
4240      borderWidth: 1,
4241      borderColor: Palette.border,
4242      backgroundColor: Palette.bgElevated,
4243    },
4244    fieldInput: {
4245      fontSize: 13,
4246      color: Palette.text,
4247      paddingVertical: 0,
4248      paddingHorizontal: 0,
4249    },
4250    emptyNote: {
4251      fontSize: 11,
4252      color: Palette.textMuted,
4253      fontStyle: 'italic',
4254      paddingHorizontal: 4,
4255      marginBottom: 4,
4256    },
4257    deleteDestBtn: {
4258      flexDirection: 'row',
4259      alignItems: 'center',
4260      justifyContent: 'center',
4261      gap: 6,
4262      paddingVertical: 10,
4263      paddingHorizontal: 12,
4264      marginTop: 16,
4265      borderRadius: 10,
4266      borderWidth: 1,
4267      borderColor: '#c87070',
4268      backgroundColor: Palette.bg,
4269    },
4270    deleteDestText: {
4271      fontSize: 12,
4272      color: '#c87070',
4273      fontWeight: '700',
4274      letterSpacing: 0.3,
4275    },
4276  });
4277  
4278  // Donation destination — NOWPayments crypto donation page. The
4279  // hostname is shown to the user as the verifiable identity; the
4280  // /donation/VFY0DTM tail is the merchant id.
4281  const DONATION_URL = 'https://nowpayments.io/donation/VFY0DTM';
4282  
4283  /**
4284   * Donate modal — short pitch + the donation URL surfaced as a panel
4285   * with two affordances: "Set as URL" drops the link into the home
4286   * field so the user can open it through any saved Flow (browser of
4287   * their choice, private mode, etc.); "Share" hands the link off
4288   * outside Warden. Copy lives on the long-press of the URL panel so
4289   * the buttons stay clean.
4290   */
4291  function DonateModal({
4292    visible,
4293    onClose,
4294    onPickUrl,
4295  }: {
4296    visible: boolean;
4297    onClose: () => void;
4298    onPickUrl: (url: string) => void;
4299  }) {
4300    return (
4301      <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
4302        <Pressable style={modalStyles.backdrop} onPress={onClose}>
4303          <Pressable style={modalStyles.sheet} onPress={() => {}}>
4304            {/* Elegant header — centered rune-flanked title, no
4305                utilitarian close X. A handshake centered between two
4306                hairline rails — clean and universally readable. */}
4307            <View style={donateStyles.headerOrnament}>
4308              <View style={donateStyles.headerRail} />
4309              <MaterialCommunityIcons
4310                name="handshake-outline"
4311                size={22}
4312                color={Palette.highlight}
4313                style={donateStyles.headerIcon}
4314              />
4315              <View style={donateStyles.headerRail} />
4316            </View>
4317            <Text style={donateStyles.headerTitle}>Donate</Text>
4318  
4319            <Text style={donateStyles.tagline}>
4320              If Warden stands watch well for you, consider a tribute.
4321              It will always be open source, free, and guarding your privacy — forever.
4322            </Text>
4323  
4324            <Text style={donateStyles.body}>
4325              Any cryptocurrency, through the link below.
4326            </Text>
4327  
4328            {/* URL panel — purely informational. Shows the user where
4329                the "Set as URL" button is about to send them. No tap
4330                or long-press affordances; the explicit button below
4331                is the only action. */}
4332            <View style={donateStyles.urlPanel}>
4333              <Ionicons name="link-outline" size={12} color={Palette.textMuted} />
4334              <Text style={donateStyles.urlText} numberOfLines={1}>
4335                {DONATION_URL}
4336              </Text>
4337            </View>
4338  
4339            <Pressable
4340              onPress={() => onPickUrl(DONATION_URL)}
4341              style={({ pressed }) => [
4342                donateStyles.primaryBtn,
4343                pressed && modalStyles.actionBtnPressed,
4344              ]}
4345              accessibilityLabel="Drop the donation URL into the link field">
4346              <Ionicons
4347                name="arrow-down-circle-outline"
4348                size={14}
4349                color={Palette.highlight}
4350              />
4351              <Text style={donateStyles.primaryBtnText}>Set as URL</Text>
4352            </Pressable>
4353  
4354            <View style={[modalStyles.explainCloseRow, { marginTop: 18 }]}>
4355              <View style={modalStyles.cycleBtnSpacer} />
4356              <Pressable
4357                onPress={onClose}
4358                style={({ pressed }) => [
4359                  editorStyles.cancelBtn,
4360                  pressed && modalStyles.actionBtnPressed,
4361                ]}>
4362                <Text style={modalStyles.closeBtnText}>Close</Text>
4363              </Pressable>
4364              <View style={modalStyles.cycleBtnSpacer} />
4365            </View>
4366          </Pressable>
4367        </Pressable>
4368      </Modal>
4369    );
4370  }
4371  
4372  const donateStyles = StyleSheet.create({
4373    // Rune-flanked ornament that replaces the standard utilitarian
4374    // header bar — matches the in-section runeDivider visual language.
4375    headerOrnament: {
4376      flexDirection: 'row',
4377      alignItems: 'center',
4378      marginTop: 2,
4379      marginBottom: 6,
4380      paddingHorizontal: 8,
4381    },
4382    headerRail: {
4383      flex: 1,
4384      height: 1,
4385      backgroundColor: Palette.accentDeep,
4386      opacity: 0.5,
4387    },
4388    headerIcon: { marginHorizontal: 14 },
4389    headerTitle: {
4390      fontSize: 17,
4391      fontWeight: '600',
4392      color: Palette.text,
4393      letterSpacing: 1.2,
4394      textAlign: 'center',
4395      marginBottom: 10,
4396    },
4397    tagline: {
4398      fontSize: 13,
4399      color: Palette.text,
4400      lineHeight: 19,
4401      marginBottom: 8,
4402      textAlign: 'center',
4403    },
4404    body: {
4405      fontSize: 12,
4406      color: Palette.textMuted,
4407      lineHeight: 17,
4408      marginBottom: 14,
4409      textAlign: 'center',
4410    },
4411    urlPanel: {
4412      flexDirection: 'row',
4413      alignItems: 'center',
4414      gap: 8,
4415      paddingVertical: 9,
4416      paddingHorizontal: 12,
4417      borderRadius: 10,
4418      borderWidth: 1,
4419      borderColor: Palette.border,
4420      backgroundColor: Palette.bgElevated,
4421      marginBottom: 14,
4422    },
4423    urlText: {
4424      flex: 1,
4425      fontSize: 12,
4426      color: Palette.text,
4427      fontFamily: Fonts?.mono,
4428    },
4429    primaryBtn: {
4430      flexDirection: 'row',
4431      alignItems: 'center',
4432      justifyContent: 'center',
4433      alignSelf: 'center',
4434      gap: 6,
4435      paddingVertical: 10,
4436      paddingHorizontal: 18,
4437      borderRadius: 10,
4438      borderWidth: 1,
4439      borderColor: Palette.highlight,
4440      backgroundColor: Palette.bgElevated,
4441    },
4442    primaryBtnText: {
4443      fontSize: 12,
4444      color: Palette.highlight,
4445      fontWeight: '700',
4446      letterSpacing: 0.3,
4447    },
4448  });
4449  
4450  function AboutModal({ visible, onClose }: { visible: boolean; onClose: () => void }) {
4451    // Plain-language descriptions for users, not engineers. The README has
4452    // the technical detail; the in-app pitch should be inviting.
4453    const pillars: { icon: keyof typeof Ionicons.glyphMap; title: string; body: string }[] = [
4454      { 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.' },
4455      { 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.' },
4456      { 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.' },
4457      { 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.' },
4458      { 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.' },
4459    ];
4460    return (
4461      <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
4462        <Pressable style={modalStyles.backdrop} onPress={onClose}>
4463          <Pressable style={modalStyles.sheet} onPress={() => {}}>
4464            {/* Centered app icon at the top — the full launcher tile with
4465                the rounded boundary, so the modal header reads as the same
4466                brand the user tapped to open it. */}
4467            <Image
4468              source={require('./assets/images/warden-mark.png')}
4469              style={aboutStyles.logo}
4470              accessibilityIgnoresInvertColors
4471            />
4472            <Text style={[modalStyles.title, aboutStyles.titleCentered]}>Warden</Text>
4473            <Text style={aboutStyles.byline}>By Vikingware</Text>
4474            <Text style={aboutStyles.tagline}>
4475              Every link doesn't have to land in your default browser.
4476              Warden hands you the choice — which browser opens it, in
4477              what mode — and quietly strips trackers and rewrites
4478              hosts through privacy front-ends before the launch ever
4479              fires.
4480            </Text>
4481  
4482            <View style={aboutStyles.grid}>
4483              {pillars.map((p) => (
4484                <View key={p.title} style={aboutStyles.pillarRow}>
4485                  <Ionicons name={p.icon} size={14} color={Palette.accentBright} style={aboutStyles.pillarIcon} />
4486                  <View style={aboutStyles.pillarBody}>
4487                    <Text style={aboutStyles.pillarTitle}>{p.title}</Text>
4488                    <Text style={aboutStyles.pillarText}>{p.body}</Text>
4489                  </View>
4490                </View>
4491              ))}
4492            </View>
4493  
4494            <Text style={aboutStyles.freeLine}>Open Source. Free. Forever.</Text>
4495            <Text style={aboutStyles.footnote}>
4496              No telemetry · MIT licensed
4497            </Text>
4498            {/* Build metadata moves into About — the home screen used to
4499                carry "v0.38.0 · 7d0b03f" but the SHA is only useful when
4500                the user actually wants to read About / file a bug. */}
4501            <Text style={aboutStyles.buildLine}>
4502              v{BUILD_VERSION} · {BUILD_COMMIT}
4503            </Text>
4504  
4505            <View style={modalStyles.explainCloseRow}>
4506              <View style={modalStyles.cycleBtnSpacer} />
4507              <Pressable
4508                onPress={onClose}
4509                style={({ pressed }) => [
4510                  editorStyles.cancelBtn,
4511                  pressed && modalStyles.actionBtnPressed,
4512                ]}>
4513                <Text style={modalStyles.closeBtnText}>Close</Text>
4514              </Pressable>
4515              <View style={modalStyles.cycleBtnSpacer} />
4516            </View>
4517          </Pressable>
4518        </Pressable>
4519      </Modal>
4520    );
4521  }
4522  
4523  const aboutStyles = StyleSheet.create({
4524    logo: {
4525      width: 72,
4526      height: 72,
4527      resizeMode: 'contain',
4528      alignSelf: 'center',
4529      marginBottom: 10,
4530      // Match the launcher icon's rounded squircle — Android renders the
4531      // adaptive icon with a system mask, but our in-app Image draws the
4532      // raw square. Clipping with a generous corner radius gives the same
4533      // silhouette without bundling a separate masked asset.
4534      borderRadius: 18,
4535      overflow: 'hidden',
4536    },
4537    titleCentered: { textAlign: 'center' },
4538    byline: {
4539      fontSize: 12,
4540      color: Palette.accentBright,
4541      fontStyle: 'italic',
4542      textAlign: 'center',
4543      letterSpacing: 0.4,
4544      marginTop: 2,
4545      marginBottom: 6,
4546    },
4547    tagline: {
4548      fontSize: 12,
4549      color: Palette.textMuted,
4550      lineHeight: 17,
4551      marginTop: 4,
4552      marginBottom: 14,
4553      textAlign: 'center',
4554    },
4555    grid: {
4556      gap: 2,
4557    },
4558    pillarRow: {
4559      flexDirection: 'row',
4560      alignItems: 'flex-start',
4561      gap: 9,
4562      paddingVertical: 6,
4563    },
4564    pillarIcon: { marginTop: 2 },
4565    pillarBody: { flex: 1 },
4566    pillarTitle: {
4567      fontSize: 12,
4568      fontWeight: '700',
4569      color: Palette.text,
4570      letterSpacing: 0.2,
4571    },
4572    pillarText: {
4573      fontSize: 11,
4574      color: Palette.textMuted,
4575      lineHeight: 15,
4576      marginTop: 1,
4577    },
4578    freeLine: {
4579      fontSize: 12,
4580      color: Palette.highlight,
4581      fontWeight: '700',
4582      letterSpacing: 0.4,
4583      textAlign: 'center',
4584      marginTop: 14,
4585    },
4586    footnote: {
4587      fontSize: 10,
4588      color: Palette.textMuted,
4589      letterSpacing: 0.5,
4590      textAlign: 'center',
4591      marginTop: 4,
4592      textTransform: 'uppercase',
4593    },
4594    buildLine: {
4595      fontSize: 10,
4596      color: Palette.textMuted,
4597      fontFamily: Fonts?.mono,
4598      textAlign: 'center',
4599      marginTop: 4,
4600      opacity: 0.7,
4601    },
4602  });
4603  
4604  /**
4605   * Per-browser score breakdown card rendered at the top of the Privacy
4606   * Features modal. Shows the two totals + their component scores so the
4607   * user can see what drove each rating.
4608   */
4609  /**
4610   * Friendly display labels for the privacytests.org bySection keys.
4611   * Anything missing falls back to the raw key.
4612   */
4613  const TEST_CATEGORY_LABEL: Record<string, string> = {
4614    session_1p:    'First-party state',
4615    session_3p:    'Third-party state',
4616    supercookies:  'Supercookies',
4617    trackers:      'Tracker blocking',
4618    query:         'Query-string tracking',
4619    https:         'HTTPS upgrades',
4620    misc:          'Misc',
4621    navigation:    'Navigation tracking',
4622    fingerprinting: 'Fingerprinting',
4623  };
4624  
4625  /**
4626   * Single component-score row rendered as a label + 0–100 progress bar.
4627   * Bar fill colour gradient: red → amber → highlight as the score climbs.
4628   */
4629  function ScoreBar({ label, score, note }: { label: string; score: number; note?: string }) {
4630    // Crude 3-stop gradient by threshold — readable and matches the dark
4631    // palette without pulling in a gradient lib.
4632    const colour =
4633      score >= 80 ? Palette.highlight :
4634      score >= 55 ? Palette.accentBright :
4635      score >= 35 ? '#d4a04a' :
4636                    '#c87070';
4637    return (
4638      <View style={modalStyles.scoreBarRow}>
4639        <View style={modalStyles.scoreBarHeader}>
4640          <Text style={modalStyles.scoreBarLabel}>{label}</Text>
4641          <Text style={modalStyles.scoreBarValue}>{score}</Text>
4642        </View>
4643        <View style={modalStyles.scoreBarTrack}>
4644          <View
4645            style={[
4646              modalStyles.scoreBarFill,
4647              { width: `${Math.max(0, Math.min(100, score))}%`, backgroundColor: colour },
4648            ]}
4649          />
4650        </View>
4651        {note ? <Text style={modalStyles.scoreBarNote}>{note}</Text> : null}
4652      </View>
4653    );
4654  }
4655  
4656  function ScoreBreakdown({
4657    browser,
4658    only,
4659  }: {
4660    browser: Browser;
4661    /** Render a single axis full-width. Omit for the two-column view. */
4662    only?: 'security' | 'privacy';
4663  }) {
4664    const p = privacyBreakdown(browser);
4665    const s = securityBreakdown(browser);
4666    const showS = only !== 'privacy';
4667    const showP = only !== 'security';
4668    return (
4669      <View style={modalStyles.scoreBreakdown}>
4670        {showS ? (
4671          <View style={modalStyles.scoreCol}>
4672            <View style={modalStyles.scoreColHeader}>
4673              <Ionicons name="shield-half-outline" size={14} color={Palette.accentBright} />
4674              <Text style={modalStyles.scoreColLabel}>Security</Text>
4675              <Text style={modalStyles.scoreColTotal}>
4676                {s.total}
4677                <Text style={modalStyles.scoreColScale}>/100</Text>
4678              </Text>
4679            </View>
4680            {s.parts.map((part, i) => (
4681              <ScoreBar key={i} label={part.label} score={part.score} note={part.note} />
4682            ))}
4683          </View>
4684        ) : null}
4685        {showS && showP ? <View style={modalStyles.scoreColDivider} /> : null}
4686        {showP ? (
4687          <View style={modalStyles.scoreCol}>
4688            <View style={modalStyles.scoreColHeader}>
4689              <Ionicons name="glasses-outline" size={14} color={Palette.accentBright} />
4690              <Text style={modalStyles.scoreColLabel}>Privacy</Text>
4691              <Text style={modalStyles.scoreColTotal}>
4692                {p.total}
4693                <Text style={modalStyles.scoreColScale}>/100</Text>
4694              </Text>
4695            </View>
4696            {p.parts.map((part, i) => (
4697              <ScoreBar key={i} label={part.label} score={part.score} note={part.note} />
4698            ))}
4699          </View>
4700        ) : null}
4701      </View>
4702    );
4703  }
4704  
4705  /**
4706   * Compact pill cluster showing what a Flow will do at a glance. Derived
4707   * directly from which intent extras are present + truthy in the Flow's
4708   * extras list, so the display can't drift from what actually gets sent.
4709   *
4710   *  - ENABLE_EPHEMERAL_BROWSING = true       → "Ephemeral" pill
4711   *  - any Chromium/Firefox incognito hint    → "Incognito" pill
4712   *  - everything else falls under            → "Normal" (muted) if no
4713   *                                              other pills render
4714   *  - "+N extras" counts user-custom extras
4715   *    (keys outside MODE_MANAGED_KEYS).
4716   */
4717  function FlowModePills({ flow }: { flow: Flow }) {
4718    const isTruthy = (v: string) => v === 'true' || v === '1';
4719    const hasKey = (key: string) =>
4720      flow.extras.some((e) => e.key === key && isTruthy(e.value));
4721    const ephemeral = hasKey('androidx.browser.customtabs.extra.ENABLE_EPHEMERAL_BROWSING');
4722    // Reuse the single source of truth for incognito-style keys —
4723    // duplicating the list here is what caused them to drift before.
4724    const incognito = INCOGNITO_KEYS.some(hasKey);
4725    // "Diff" extras = keys we don't recognise as either mode-managed or
4726    // curated suggestions. Anything outside our presets is the user
4727    // setting their own intent extras, so we count them separately.
4728    const knownKeys = new Set<string>([
4729      ...MODE_MANAGED_KEYS,
4730      ...EXTRA_SUGGESTIONS.map((s) => s.key),
4731    ]);
4732    const userExtras = flow.extras.filter((e) => !knownKeys.has(e.key));
4733  
4734    // Ephemeral implies the launch goes through the Custom Tab shape
4735    // (CCT_SESSION marker on the intent), so a "Minitab" tag rides along
4736    // with it whenever Ephemeral is on.
4737    const minitab = ephemeral;
4738  
4739    if (!ephemeral && !incognito && !minitab && userExtras.length === 0) {
4740      // Plain inline text (no pill chrome) so the label sits flush
4741      // with the browser-name baseline above — matches the active-mode
4742      // rendering shape, just in the muted color.
4743      return (
4744        <View style={styles.modePillRow}>
4745          <Text style={styles.modeInlineTextMuted}>Normal</Text>
4746        </View>
4747      );
4748    }
4749    // Active modes render as plain uppercase labels joined by middot
4750    // separators — no pill chrome around them. The "+N extras" counter
4751    // stays in its own muted pill since it's a different kind of indicator.
4752    // Order: Incognito → Ephemeral → Mini. Incognito leads because it's
4753    // the privacy primitive most users recognise; Ephemeral is the
4754    // browser-specific stronger variant; Mini is a UI shape, last.
4755    const activeSegments: string[] = [];
4756    if (incognito) activeSegments.push('Incognito');
4757    if (ephemeral) activeSegments.push('Ephemeral');
4758    if (minitab)   activeSegments.push('Mini');
4759    return (
4760      <View style={styles.modePillRow}>
4761        {activeSegments.length > 0 ? (
4762          <Text style={styles.modeInlineText}>
4763            {activeSegments.map((label, i) => (
4764              <React.Fragment key={label}>
4765                {i > 0 ? <Text style={styles.modeInlineSep}> • </Text> : null}
4766                <Text>{label}</Text>
4767              </React.Fragment>
4768            ))}
4769          </Text>
4770        ) : null}
4771        {userExtras.length > 0 ? (
4772          <View style={styles.modePillMuted}>
4773            <Text style={styles.modePillTextMuted}>
4774              +{userExtras.length} extra{userExtras.length === 1 ? '' : 's'}
4775            </Text>
4776          </View>
4777        ) : null}
4778      </View>
4779    );
4780  }
4781  
4782  function RatingsRow({ browser }: { browser: Browser }) {
4783    const p = privacyScore(browser);
4784    const s = securityScore(browser);
4785    return (
4786      <View style={styles.ratingsRow}>
4787        <View style={styles.ratingPill}>
4788          <Ionicons name="shield-half-outline" size={11} color={Palette.accentBright} />
4789          <Text style={styles.ratingScore}>{s}</Text>
4790          <Text style={styles.ratingScale}>/100</Text>
4791        </View>
4792        <View style={styles.ratingPill}>
4793          <Ionicons name="glasses-outline" size={11} color={Palette.accentBright} />
4794          <Text style={styles.ratingScore}>{p}</Text>
4795          <Text style={styles.ratingScale}>/100</Text>
4796        </View>
4797      </View>
4798    );
4799  }
4800  
4801  // ────────────────────────────────────────────────────────────────────────
4802  // Flow action sheet — long-press on a Flow row.
4803  // Provides edit / delete / reorder / autoFire toggle / once-overrides.
4804  // ────────────────────────────────────────────────────────────────────────
4805  
4806  function FlowActionSheet({
4807    flow, canMoveUp, canMoveDown,
4808    onEdit, onMove, onClose,
4809  }: {
4810    flow: Flow | null;
4811    canMoveUp: boolean;
4812    canMoveDown: boolean;
4813    onEdit: () => void;
4814    onMove: (delta: -1 | 1) => void;
4815    onClose: () => void;
4816  }) {
4817    if (!flow) {
4818      return <Modal visible={false} transparent onRequestClose={onClose}><View /></Modal>;
4819    }
4820    const b = BROWSERS.find((x) => x.pkg === flow.browserPkg);
4821    // Title row = browser name; subtitle = tag if set.
4822    const title = b?.name ?? flow.browserPkg;
4823    const tag = (flow.title ?? '').trim();
4824    return (
4825      <Modal visible transparent animationType="fade" onRequestClose={onClose}>
4826        <Pressable style={modalStyles.backdrop} onPress={onClose}>
4827          <Pressable style={modalStyles.sheet} onPress={() => {}}>
4828            <View style={modalStyles.header}>
4829              {b && ASSET_ICONS[b.id] ? (
4830                <Image source={ASSET_ICONS[b.id]} style={modalStyles.headerIcon} />
4831              ) : (
4832                <View
4833                  style={[
4834                    modalStyles.headerIcon,
4835                    { backgroundColor: b?.tint ?? Palette.bgElevated },
4836                  ]}
4837                />
4838              )}
4839              <View style={modalStyles.headerText}>
4840                <Text style={modalStyles.title}>{title}</Text>
4841                {tag ? (
4842                  <Text style={modalStyles.subtitle}>{tag}</Text>
4843                ) : null}
4844              </View>
4845            </View>
4846  
4847            <View style={modalStyles.actionList}>
4848              <Pressable
4849                onPress={onEdit}
4850                style={({ pressed }) => [
4851                  modalStyles.actionBtnPrimary,
4852                  pressed && modalStyles.actionBtnPressed,
4853                ]}>
4854                <Ionicons name="create-outline" size={18} color={Palette.highlight} />
4855                <Text style={modalStyles.actionBtnPrimaryText}>Edit</Text>
4856              </Pressable>
4857  
4858              {canMoveUp || canMoveDown ? (
4859                <View style={modalStyles.moveRow}>
4860                  <Pressable
4861                    onPress={() => canMoveUp && onMove(-1)}
4862                    disabled={!canMoveUp}
4863                    style={({ pressed }) => [
4864                      modalStyles.actionBtn,
4865                      modalStyles.moveBtn,
4866                      !canMoveUp && modalStyles.moveBtnDisabled,
4867                      pressed && modalStyles.actionBtnPressed,
4868                    ]}>
4869                    <Ionicons
4870                      name="arrow-up"
4871                      size={18}
4872                      color={canMoveUp ? Palette.accentBright : Palette.textMuted}
4873                    />
4874                    <Text style={modalStyles.actionBtnText}>Move up</Text>
4875                  </Pressable>
4876                  <Pressable
4877                    onPress={() => canMoveDown && onMove(1)}
4878                    disabled={!canMoveDown}
4879                    style={({ pressed }) => [
4880                      modalStyles.actionBtn,
4881                      modalStyles.moveBtn,
4882                      !canMoveDown && modalStyles.moveBtnDisabled,
4883                      pressed && modalStyles.actionBtnPressed,
4884                    ]}>
4885                    <Ionicons
4886                      name="arrow-down"
4887                      size={18}
4888                      color={canMoveDown ? Palette.accentBright : Palette.textMuted}
4889                    />
4890                    <Text style={modalStyles.actionBtnText}>Move down</Text>
4891                  </Pressable>
4892                </View>
4893              ) : null}
4894            </View>
4895            {/* No explicit Close button — the backdrop Pressable above
4896                dismisses on tap-outside (and Android system-back). */}
4897          </Pressable>
4898        </Pressable>
4899      </Modal>
4900    );
4901  }
4902  
4903  // ────────────────────────────────────────────────────────────────────────
4904  // Flow editor modal
4905  // ────────────────────────────────────────────────────────────────────────
4906  
4907  /**
4908   * Lightweight info dialog — replaces native `Alert.alert` for in-app
4909   * explanations so the chrome matches the rest of the modal stack.
4910   * Renders a centered sheet with an optional Ionicon, a title, a body
4911   * paragraph, and a single Got it action. Tap-backdrop also closes.
4912   */
4913  /**
4914   * "Back" footer pill used by nested config modals (proxies list,
4915   * per-destination detail, tracking list, …) — replaces the previous
4916   * "Done"/"Close" label so the stack reads like a forward/back
4917   * browsing flow rather than a series of commit dialogs.
4918   */
4919  function BackPill({ onPress, label = 'Back' }: { onPress: () => void; label?: string }) {
4920    return (
4921      <View style={modalStyles.backCloseRow}>
4922        <Pressable
4923          onPress={onPress}
4924          style={({ pressed }) => [
4925            editorStyles.cancelBtn,
4926            editorStyles.backPill,
4927            pressed && modalStyles.actionBtnPressed,
4928          ]}>
4929          <Ionicons
4930            name="chevron-back"
4931            size={14}
4932            color={Palette.text}
4933            style={{ marginRight: 4 }}
4934          />
4935          <Text style={modalStyles.closeBtnText}>{label}</Text>
4936        </Pressable>
4937      </View>
4938    );
4939  }
4940  
4941  function InfoModal({
4942    visible, title, body, icon, onClose,
4943  }: {
4944    visible: boolean;
4945    title: string;
4946    body: string;
4947    icon?: keyof typeof Ionicons.glyphMap;
4948    onClose: () => void;
4949  }) {
4950    return (
4951      <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
4952        <Pressable style={modalStyles.backdrop} onPress={onClose}>
4953          <Pressable style={[modalStyles.sheet, infoModalStyles.sheet]} onPress={() => {}}>
4954            <View style={infoModalStyles.header}>
4955              {icon ? (
4956                <Ionicons name={icon} size={18} color={Palette.highlight} />
4957              ) : null}
4958              <Text style={modalStyles.title}>{title}</Text>
4959            </View>
4960            <Text style={infoModalStyles.body}>{body}</Text>
4961            <View style={modalStyles.explainCloseRow}>
4962              <View style={modalStyles.cycleBtnSpacer} />
4963              <Pressable
4964                onPress={onClose}
4965                style={({ pressed }) => [
4966                  editorStyles.cancelBtn,
4967                  pressed && modalStyles.actionBtnPressed,
4968                ]}>
4969                <Text style={modalStyles.closeBtnText}>Got it</Text>
4970              </Pressable>
4971              <View style={modalStyles.cycleBtnSpacer} />
4972            </View>
4973          </Pressable>
4974        </Pressable>
4975      </Modal>
4976    );
4977  }
4978  
4979  const infoModalStyles = StyleSheet.create({
4980    sheet: { maxWidth: 360 },
4981    header: {
4982      flexDirection: 'row',
4983      alignItems: 'center',
4984      justifyContent: 'center',
4985      gap: 8,
4986      marginBottom: 8,
4987    },
4988    body: {
4989      fontSize: 12,
4990      color: Palette.textMuted,
4991      lineHeight: 18,
4992      marginBottom: 4,
4993    },
4994  });
4995  
4996  function FlowEditorModal({
4997    flow, installed, icons, isExisting,
4998    onSave, onDelete, onClose, onMove, canMoveUp = false, canMoveDown = false,
4999  }: {
5000    flow: Flow | null;
5001    installed: Set<string>;
5002    icons: Record<string, string>;
5003    isExisting: boolean;
5004    onSave: (f: Flow) => void;
5005    onDelete: (id: string) => void;
5006    onClose: () => void;
5007    /** Reorder this Flow in the list without leaving the editor. Same
5008     *  affordance the FlowActionSheet exposes — surfaced here so the
5009     *  user can edit + reposition in one place. */
5010    onMove?: (delta: -1 | 1) => void;
5011    canMoveUp?: boolean;
5012    canMoveDown?: boolean;
5013  }) {
5014    // Bound the form ScrollView with a pixel cap — `flex: 1` here
5015    // breaks the KeyboardAvoidingView height negotiation (collapses
5016    // form to 0), and `flexShrink: 1` alone leaves the ScrollView
5017    // sized to its content so it never overflows. ~60% of viewport
5018    // leaves room for header + Save/Cancel action row.
5019    const { height: viewportH } = useWindowDimensions();
5020    const scrollMaxH = Math.max(260, Math.floor(viewportH * 0.6));
5021    // Local copy of the flow under edit. Reset whenever the prop changes.
5022    const [draft, setDraft] = useState<Flow | null>(flow);
5023    const [browserPickerOpen, setBrowserPickerOpen] = useState(false);
5024    const [extraPickerOpen, setExtraPickerOpen] = useState(false);
5025    const [showExplain, setShowExplain] = useState(false);
5026    const [autolaunchInfoOpen, setAutolaunchInfoOpen] = useState(false);
5027    const [tagInfoOpen, setTagInfoOpen] = useState(false);
5028    // When the user cycles the encyclopedia with the ‹ / › chevrons, the
5029    // viewed browser diverges from the Flow's selected browser. We keep
5030    // that purely visual override here; it does NOT affect draft.browserPkg.
5031    const [explainOverride, setExplainOverride] = useState<Browser | null>(null);
5032  
5033    useEffect(() => {
5034      setDraft(flow);
5035      // Reset transient editor state when opening a different Flow.
5036      setShowExplain(false);
5037      setExplainOverride(null);
5038    }, [flow]);
5039  
5040    if (!draft) {
5041      return <Modal visible={false} transparent onRequestClose={onClose}><View /></Modal>;
5042    }
5043  
5044    const browser = BROWSERS.find((b) => b.pkg === draft.browserPkg);
5045  
5046    const update = (patch: Partial<Flow>) => setDraft({ ...draft, ...patch });
5047  
5048    const addSuggestion = (s: ExtraSuggestion) => {
5049      const next: FlowExtra = { key: s.key, type: s.type, value: s.defaultValue };
5050      update({ extras: [...draft.extras, next] });
5051      setExtraPickerOpen(false);
5052    };
5053    const addCustom = () => {
5054      const next: FlowExtra = { key: '', type: 'string', value: '' };
5055      update({ extras: [...draft.extras, next] });
5056      setExtraPickerOpen(false);
5057    };
5058  
5059    return (
5060      <Modal
5061        visible
5062        transparent
5063        animationType="fade"
5064        onRequestClose={onClose}>
5065        <Pressable style={modalStyles.backdrop} onPress={onClose}>
5066          <KeyboardAvoidingView
5067            behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
5068            style={modalStyles.keyboardWrap}
5069            pointerEvents="box-none">
5070          <Pressable style={modalStyles.sheetTall} onPress={() => {}}>
5071            {/* Header — big tappable browser icon (re-pick) + title +
5072                info-stats pill + close. */}
5073            <View style={editorStyles.editorHeader}>
5074              <Pressable
5075                onPress={() => setBrowserPickerOpen(true)}
5076                style={({ pressed }) => [
5077                  editorStyles.editorHeaderIconWrap,
5078                  pressed && modalStyles.actionBtnPressed,
5079                ]}>
5080                {browser && ASSET_ICONS[browser.id] ? (
5081                  <Image
5082                    source={ASSET_ICONS[browser.id]}
5083                    style={editorStyles.editorHeaderIcon}
5084                  />
5085                ) : (
5086                  <View
5087                    style={[
5088                      editorStyles.editorHeaderIcon,
5089                      { backgroundColor: browser?.tint ?? Palette.bgElevated },
5090                    ]}
5091                  />
5092                )}
5093              </Pressable>
5094              <View style={editorStyles.editorHeaderText}>
5095                <Text style={editorStyles.editorTitle}>
5096                  {isExisting ? 'Edit Flow' : 'New Flow'}
5097                </Text>
5098                {browser ? (
5099                  <Text style={editorStyles.editorSubtitle} numberOfLines={1}>
5100                    {browser.name}
5101                  </Text>
5102                ) : null}
5103              </View>
5104              {browser ? (
5105                <Pressable
5106                  onPress={() => setShowExplain(true)}
5107                  hitSlop={6}
5108                  style={({ pressed }) => [
5109                    editorStyles.infoStatsPill,
5110                    pressed && modalStyles.actionBtnPressed,
5111                  ]}>
5112                  <Ionicons
5113                    name="information-circle"
5114                    size={15}
5115                    color={Palette.accentBright}
5116                  />
5117                  <View style={editorStyles.infoStatsDivider} />
5118                  <View style={editorStyles.infoStatsScore}>
5119                    <Ionicons name="shield-half-outline" size={11} color={Palette.textMuted} />
5120                    <Text style={editorStyles.selectScoreText}>{securityScore(browser)}</Text>
5121                  </View>
5122                  <View style={editorStyles.infoStatsDivider} />
5123                  <View style={editorStyles.infoStatsScore}>
5124                    <Ionicons name="glasses-outline" size={11} color={Palette.textMuted} />
5125                    <Text style={editorStyles.selectScoreText}>{privacyScore(browser)}</Text>
5126                  </View>
5127                </Pressable>
5128              ) : null}
5129              <Pressable
5130                onPress={onClose}
5131                hitSlop={10}
5132                style={({ pressed }) => [
5133                  editorStyles.editorClose,
5134                  pressed && modalStyles.actionBtnPressed,
5135                ]}>
5136                <Ionicons name="close" size={18} color={Palette.textMuted} />
5137              </Pressable>
5138            </View>
5139  
5140            <ScrollView
5141              style={[modalStyles.explainScroll, { maxHeight: scrollMaxH }]}
5142              contentContainerStyle={editorStyles.formBody}
5143              keyboardShouldPersistTaps="handled"
5144              persistentScrollbar
5145              showsVerticalScrollIndicator
5146              nestedScrollEnabled>
5147              {/* Tag — single-row card mirroring the Autolaunch card.
5148                  Left side: icon + label + (i) info button (identical
5149                  layout to Autolaunch). Right side: the input field,
5150                  right-aligned so the value/placeholder sits in the
5151                  same column as the switch on the row below. */}
5152              <View style={editorStyles.fieldCard}>
5153                <Ionicons
5154                  name="pricetag-outline"
5155                  size={14}
5156                  color={Palette.highlight}
5157                />
5158                <Text style={editorStyles.autoFireTitle}>Tag</Text>
5159                <Pressable
5160                  onPress={() => setTagInfoOpen(true)}
5161                  hitSlop={8}
5162                  style={({ pressed }) => [
5163                    editorStyles.autoFireInfo,
5164                    pressed && modalStyles.actionBtnPressed,
5165                  ]}>
5166                  <Ionicons
5167                    name="information-circle-outline"
5168                    size={15}
5169                    color={Palette.textMuted}
5170                  />
5171                </Pressable>
5172                <TextInput
5173                  value={draft.title}
5174                  onChangeText={(t) => update({ title: t })}
5175                  placeholder="(optional)"
5176                  placeholderTextColor={Palette.textMuted}
5177                  style={editorStyles.fieldCardInput}
5178                  autoCapitalize="sentences"
5179                  autoCorrect={false}
5180                />
5181              </View>
5182  
5183              {/* Profile — single-row card: shield icon + label + segmented
5184                  picker. Changing profile resets extras to profile defaults. */}
5185              <View style={editorStyles.fieldCard}>
5186                <Ionicons
5187                  name="shield-outline"
5188                  size={14}
5189                  color={Palette.highlight}
5190                />
5191                <Text style={editorStyles.autoFireTitle}>Profile</Text>
5192                <View style={editorStyles.profilePickerRow}>
5193                  {PROFILES.map((p) => {
5194                    const active = p === draft.profile;
5195                    return (
5196                      <Pressable
5197                        key={p}
5198                        onPress={() => {
5199                          if (p === draft.profile) return;
5200                          const b = BROWSERS.find((x) => x.pkg === draft.browserPkg);
5201                          const d = defaultsForProfile(p, draft.browserPkg, b);
5202                          update({ profile: p, mode: d.mode, extras: d.extras });
5203                        }}
5204                        style={({ pressed }) => [
5205                          editorStyles.profilePickerBtn,
5206                          active && (
5207                            p === 'privacy' ? editorStyles.profilePickerBtnPrivacy
5208                            : p === 'work' ? editorStyles.profilePickerBtnWork
5209                            : editorStyles.profilePickerBtnRaw
5210                          ),
5211                          pressed && modalStyles.actionBtnPressed,
5212                        ]}>
5213                        <Text style={[
5214                          editorStyles.profilePickerBtnText,
5215                          active && editorStyles.profilePickerBtnTextActive,
5216                        ]}>
5217                          {PROFILE_LABEL[p]}
5218                        </Text>
5219                      </Pressable>
5220                    );
5221                  })}
5222                </View>
5223              </View>
5224  
5225              {/* Autolaunch — single-row card: flash icon + label +
5226                  (i) doc + iOS-style switch. No subtitle; the (i) opens
5227                  the explanatory InfoModal when needed. */}
5228              <Pressable
5229                onPress={() => update({ autoFire: !draft.autoFire })}
5230                style={({ pressed }) => [
5231                  editorStyles.autoFireRow,
5232                  draft.autoFire && editorStyles.autoFireRowOn,
5233                  pressed && modalStyles.actionBtnPressed,
5234                ]}>
5235                <Ionicons name="flash" size={14} color={Palette.highlight} />
5236                <Text style={editorStyles.autoFireTitle}>Autolaunch</Text>
5237                <Pressable
5238                  onPress={() => setAutolaunchInfoOpen(true)}
5239                  hitSlop={8}
5240                  style={({ pressed }) => [
5241                    editorStyles.autoFireInfo,
5242                    pressed && modalStyles.actionBtnPressed,
5243                  ]}>
5244                  <Ionicons
5245                    name="information-circle-outline"
5246                    size={15}
5247                    color={Palette.textMuted}
5248                  />
5249                </Pressable>
5250                {/* Flex spacer pushes the switch to the right while
5251                    keeping the info icon snug against the title. */}
5252                <View style={{ flex: 1 }} />
5253                <View
5254                  style={[
5255                    editorStyles.switchTrack,
5256                    draft.autoFire && editorStyles.switchTrackOn,
5257                  ]}>
5258                  <View
5259                    style={[
5260                      editorStyles.switchThumb,
5261                      draft.autoFire && editorStyles.switchThumbOn,
5262                    ]}
5263                  />
5264                </View>
5265              </Pressable>
5266  
5267              {/* Extras / Behaviour — clear subpanel with title bar and
5268                  inline Add-new chip. Every entry maps to a documented
5269                  intent extra; mode is derived from these at save time. */}
5270              <View style={editorStyles.extrasPanel}>
5271                <View style={editorStyles.extrasPanelHeader}>
5272                  <Text style={editorStyles.extrasPanelTitle}>Extras / Behaviour</Text>
5273                  <Pressable
5274                    onPress={() => setExtraPickerOpen(true)}
5275                    hitSlop={6}
5276                    style={({ pressed }) => [
5277                      editorStyles.addNewChip,
5278                      pressed && modalStyles.actionBtnPressed,
5279                    ]}>
5280                    <Ionicons name="add" size={12} color={Palette.highlight} />
5281                    <Text style={editorStyles.addNewChipText}>Add new</Text>
5282                  </Pressable>
5283                </View>
5284                <View style={editorStyles.extrasTable}>
5285                {draft.extras.length === 0 ? (
5286                  <Text style={editorStyles.extrasEmpty}>None</Text>
5287                ) : null}
5288                {draft.extras.map((e, i) => {
5289                  // Reactive: if the key matches a curated suggestion (filtered
5290                  // to the active browser family), surface an info button that
5291                  // pops the suggestion's documentation.
5292                  const doc = lookupSuggestion(e.key, draft.browserPkg);
5293                  return (
5294                  <View key={i} style={editorStyles.extraRow}>
5295                    <View style={editorStyles.extraFields}>
5296                      <View style={editorStyles.extraKeyRow}>
5297                        <TextInput
5298                          value={e.key}
5299                          onChangeText={(v) => {
5300                            const next = [...draft.extras];
5301                            next[i] = { ...e, key: v };
5302                            update({ extras: next });
5303                          }}
5304                          placeholder="extra.key"
5305                          placeholderTextColor={Palette.textMuted}
5306                          style={[editorStyles.extraKey, { flex: 1 }]}
5307                          autoCapitalize="none"
5308                          autoCorrect={false}
5309                        />
5310                        {doc ? (
5311                          <Pressable
5312                            onPress={() =>
5313                              Alert.alert(
5314                                doc.label,
5315                                `${doc.hint ?? ''}\n\nKey:\n${doc.key}\n\nType: ${doc.type}`.trim(),
5316                                [{ text: 'OK' }],
5317                              )
5318                            }
5319                            hitSlop={8}
5320                            style={({ pressed }) => [
5321                              editorStyles.extraDocBtn,
5322                              pressed && modalStyles.actionBtnPressed,
5323                            ]}>
5324                            <Ionicons
5325                              name="information-circle"
5326                              size={14}
5327                              color={Palette.accentBright}
5328                            />
5329                          </Pressable>
5330                        ) : null}
5331                      </View>
5332                      <View style={editorStyles.extraValueRow}>
5333                        <Pressable
5334                          onPress={() => {
5335                            const next = [...draft.extras];
5336                            const cycle: FlowExtra['type'][] = ['bool', 'string', 'int'];
5337                            const ix = (cycle.indexOf(e.type) + 1) % cycle.length;
5338                            next[i] = { ...e, type: cycle[ix] };
5339                            update({ extras: next });
5340                          }}
5341                          style={({ pressed }) => [
5342                            editorStyles.typeChip,
5343                            pressed && modalStyles.actionBtnPressed,
5344                          ]}>
5345                          <Text style={editorStyles.typeChipText}>{e.type}</Text>
5346                        </Pressable>
5347                        {(() => {
5348                          // Tap-to-cycle pill for enumerated values:
5349                          //  - bool      → 'true' / 'false'
5350                          //  - known enum (e.g. CCT color scheme) → the
5351                          //    suggestion's declared values
5352                          // Anything else stays a free-text input.
5353                          const enumValues =
5354                            e.type === 'bool'
5355                              ? [
5356                                  { label: 'true',  value: 'true'  },
5357                                  { label: 'false', value: 'false' },
5358                                ]
5359                              : doc?.values ?? null;
5360                          if (!enumValues) {
5361                            return (
5362                              <TextInput
5363                                value={e.value}
5364                                onChangeText={(v) => {
5365                                  const next = [...draft.extras];
5366                                  next[i] = { ...e, value: v };
5367                                  update({ extras: next });
5368                                }}
5369                                placeholder=""
5370                                placeholderTextColor={Palette.textMuted}
5371                                style={editorStyles.extraValue}
5372                                keyboardType={e.type === 'int' ? 'numeric' : 'default'}
5373                                autoCapitalize="none"
5374                                autoCorrect={false}
5375                              />
5376                            );
5377                          }
5378                          const ix = Math.max(
5379                            0,
5380                            enumValues.findIndex((v) => v.value === e.value),
5381                          );
5382                          const current = enumValues[ix] ?? enumValues[0];
5383                          return (
5384                            <Pressable
5385                              onPress={() => {
5386                                const nextIx = (ix + 1) % enumValues.length;
5387                                const next = [...draft.extras];
5388                                next[i] = { ...e, value: enumValues[nextIx].value };
5389                                update({ extras: next });
5390                              }}
5391                              style={({ pressed }) => [
5392                                editorStyles.extraValue,
5393                                editorStyles.extraValueDropdown,
5394                                pressed && modalStyles.actionBtnPressed,
5395                              ]}>
5396                              <Text style={editorStyles.extraValueDropdownText}>
5397                                {current.label}
5398                              </Text>
5399                              <Ionicons
5400                                name="chevron-down"
5401                                size={10}
5402                                color={Palette.accentBright}
5403                              />
5404                            </Pressable>
5405                          );
5406                        })()}
5407                      </View>
5408                    </View>
5409                    <Pressable
5410                      onPress={() => {
5411                        const next = [...draft.extras];
5412                        next.splice(i, 1);
5413                        update({ extras: next });
5414                      }}
5415                      hitSlop={6}
5416                      style={({ pressed }) => [
5417                        editorStyles.extraRemove,
5418                        pressed && modalStyles.actionBtnPressed,
5419                      ]}>
5420                      <Ionicons name="close" size={14} color={Palette.textMuted} />
5421                    </Pressable>
5422                  </View>
5423                  );
5424                })}
5425                </View>
5426              </View>
5427            </ScrollView>
5428  
5429            {/* Footer actions */}
5430            <View style={editorStyles.footer}>
5431              {isExisting ? (
5432                <Pressable
5433                  onPress={() => onDelete(draft.id)}
5434                  style={({ pressed }) => [
5435                    editorStyles.deleteBtn,
5436                    pressed && modalStyles.actionBtnPressed,
5437                  ]}>
5438                  <Ionicons name="trash-outline" size={14} color="#f0a0a0" />
5439                  <Text style={editorStyles.deleteBtnText}>Delete</Text>
5440                </Pressable>
5441              ) : <View style={{ flex: 1 }} />}
5442              {/* Reorder the Flow in the list without leaving the editor.
5443                  Only shown for existing flows (drafts have no position
5444                  yet). Each button greys + ignores the tap at the
5445                  respective list edge. */}
5446              {isExisting && onMove ? (
5447                <>
5448                  <Pressable
5449                    onPress={() => canMoveUp && onMove(-1)}
5450                    disabled={!canMoveUp}
5451                    accessibilityLabel="Move flow up"
5452                    style={({ pressed }) => [
5453                      editorStyles.moveBtn,
5454                      !canMoveUp && editorStyles.moveBtnDisabled,
5455                      canMoveUp && pressed && modalStyles.actionBtnPressed,
5456                    ]}>
5457                    <Ionicons
5458                      name="arrow-up"
5459                      size={14}
5460                      color={canMoveUp ? Palette.accentBright : Palette.textMuted}
5461                    />
5462                  </Pressable>
5463                  <Pressable
5464                    onPress={() => canMoveDown && onMove(1)}
5465                    disabled={!canMoveDown}
5466                    accessibilityLabel="Move flow down"
5467                    style={({ pressed }) => [
5468                      editorStyles.moveBtn,
5469                      !canMoveDown && editorStyles.moveBtnDisabled,
5470                      canMoveDown && pressed && modalStyles.actionBtnPressed,
5471                    ]}>
5472                    <Ionicons
5473                      name="arrow-down"
5474                      size={14}
5475                      color={canMoveDown ? Palette.accentBright : Palette.textMuted}
5476                    />
5477                  </Pressable>
5478                </>
5479              ) : null}
5480              <Pressable
5481                onPress={onClose}
5482                style={({ pressed }) => [
5483                  editorStyles.cancelBtn,
5484                  pressed && modalStyles.actionBtnPressed,
5485                ]}>
5486                <Text style={modalStyles.closeBtnText}>Cancel</Text>
5487              </Pressable>
5488              <Pressable
5489                onPress={() => onSave({ ...draft, mode: deriveModeFromExtras(draft.extras) })}
5490                style={({ pressed }) => [
5491                  editorStyles.saveBtn,
5492                  pressed && modalStyles.actionBtnPressed,
5493                ]}>
5494                <Text style={editorStyles.saveBtnText}>Save</Text>
5495              </Pressable>
5496            </View>
5497          </Pressable>
5498          </KeyboardAvoidingView>
5499        </Pressable>
5500  
5501        <BrowserPicker
5502          visible={browserPickerOpen}
5503          installed={installed}
5504          icons={icons}
5505          selected={draft.browserPkg}
5506          onPick={(pkg) => {
5507            update({ browserPkg: pkg });
5508            setBrowserPickerOpen(false);
5509          }}
5510          onClose={() => setBrowserPickerOpen(false)}
5511        />
5512  
5513        <ExtraPicker
5514          visible={extraPickerOpen}
5515          suggestions={suggestionsFor(draft.browserPkg).filter(
5516            // Hide curated suggestions the Flow already carries — adding
5517            // them twice would just duplicate the entry.
5518            (s) => !draft.extras.some((e) => e.key === s.key),
5519          )}
5520          maxPrivacyPreview={extrasForMode(draft.browserPkg, true, true)}
5521          maxPrivacyAlreadySet={isMaxPrivacy(draft)}
5522          hasReferrerOverride={draft.extras.some(
5523            (e) => e.key === REFERRER_EXTRA_KEY && e.value.length > 0,
5524          )}
5525          onSetMaxPrivacy={() => {
5526            update({
5527              extras: syncExtrasWithMode(
5528                draft.extras,
5529                draft.browserPkg,
5530                true,
5531                true,
5532              ),
5533            });
5534            setExtraPickerOpen(false);
5535          }}
5536          onPickSuggestion={addSuggestion}
5537          onPickCustom={addCustom}
5538          onClose={() => setExtraPickerOpen(false)}
5539        />
5540  
5541        <InfoModal
5542          visible={autolaunchInfoOpen}
5543          title="Autolaunch"
5544          icon="flash"
5545          body={
5546            'When Warden receives a shared link, this Flow fires instantly '
5547            + 'without showing the menu. Only one Flow can be set as the '
5548            + 'Autolaunch target — enabling it here clears it from any '
5549            + 'other Flow.'
5550          }
5551          onClose={() => setAutolaunchInfoOpen(false)}
5552        />
5553  
5554        <InfoModal
5555          visible={tagInfoOpen}
5556          title="Tag"
5557          icon="pricetag-outline"
5558          body={
5559            'A short free-form label. When set, it appears as a small pill '
5560            + "next to the browser's name on the Flow row — handy for "
5561            + 'telling apart multiple Flows that use the same browser '
5562            + '(e.g. "Work" vs "Throwaway"). Leave it blank to show just '
5563            + 'the browser name.'
5564          }
5565          onClose={() => setTagInfoOpen(false)}
5566        />
5567  
5568        <PrivacyExplanationModal
5569          browser={showExplain ? (explainOverride ?? browser ?? null) : null}
5570          list={BROWSERS}
5571          onNavigate={(b) => setExplainOverride(b)}
5572          onClose={() => { setShowExplain(false); setExplainOverride(null); }}
5573        />
5574      </Modal>
5575    );
5576  }
5577  
5578  /**
5579   * Sortable column header used inside the BrowserPicker table. Renders
5580   * the label plus a chevron when this column is the active sort.
5581   */
5582  function SortHeader({
5583    label, active, dir, onPress, style, align = 'center', icon,
5584  }: {
5585    label: string;
5586    active: boolean;
5587    dir: 'asc' | 'desc';
5588    onPress: () => void;
5589    style?: any;
5590    /** Horizontal alignment of the label inside the cell. Browser header
5591     *  uses 'flex-start' (left-aligned to match the row content). */
5592    align?: 'flex-start' | 'center' | 'flex-end';
5593    /** Optional Ionicons glyph rendered before the label. Used on the
5594     *  Sec / Priv headers so the column icon doubles as a legend. */
5595    icon?: keyof typeof Ionicons.glyphMap;
5596  }) {
5597    return (
5598      <Pressable
5599        onPress={onPress}
5600        hitSlop={4}
5601        style={({ pressed }) => [
5602          editorStyles.sortHeader,
5603          { justifyContent: align },
5604          style,
5605          pressed && modalStyles.actionBtnPressed,
5606        ]}>
5607        {icon ? (
5608          <Ionicons
5609            name={icon}
5610            size={11}
5611            color={active ? Palette.accentBright : Palette.textMuted}
5612            style={{ marginRight: 3 }}
5613          />
5614        ) : null}
5615        <Text
5616          style={[
5617            editorStyles.tableHeaderText,
5618            active && editorStyles.tableHeaderTextActive,
5619          ]}>
5620          {label}
5621        </Text>
5622        {active ? (
5623          <Ionicons
5624            name={dir === 'desc' ? 'chevron-down' : 'chevron-up'}
5625            size={11}
5626            color={Palette.accentBright}
5627            style={{ marginLeft: 2 }}
5628          />
5629        ) : null}
5630      </Pressable>
5631    );
5632  }
5633  
5634  function BrowserPicker({
5635    visible, installed, icons, selected, onPick, onClose,
5636  }: {
5637    visible: boolean;
5638    installed: Set<string>;
5639    icons: Record<string, string>;
5640    /** Currently selected browser package, if any. Used to highlight the
5641     *  active row when re-opening the picker on an existing Flow. */
5642    selected: string;
5643    onPick: (pkg: string) => void;
5644    onClose: () => void;
5645  }) {
5646    const runicNames = React.useContext(RunicNamesContext);
5647    // Tapping a row opens the Privacy Features modal for that browser.
5648    // The modal's "Select" button is what commits — there is no separate
5649    // Continue step. Lets the user always preview a browser before
5650    // committing, which is the same affordance the old info-pill provided
5651    // but applied to the whole row.
5652    // Tap a row → sets the *pending* selection (visualised by the row's
5653    // highlight border). The Continue button at the bottom is what
5654    // actually commits, mirroring the staged selection flow used by the
5655    // mode picker. Long-press opens the encyclopedia for that browser —
5656    // info-only in this context, since selection now lives on the row tap.
5657    const [pending, setPending] = useState<string>(selected);
5658    useEffect(() => {
5659      if (visible) setPending(selected);
5660    }, [visible, selected]);
5661    const [explainBrowser, setExplainBrowser] = useState<Browser | null>(null);
5662    const [gradesInfoOpen, setGradesInfoOpen] = useState(false);
5663    // Sortable table columns. Defaults to Installed-desc which collapses
5664    // to the "auto" ranking — installed first, security desc, privacy desc.
5665    // Tapping the active column flips direction; tapping a different
5666    // column switches and resets to the column's default direction.
5667    // Three-state sorting per column:
5668    //   reset (sortKey = null) → column's default dir → opposite dir → reset
5669    // Reset uses the "auto" ranking: installed first, then security desc,
5670    // then privacy desc — the same baseline the picker shipped with.
5671    type SortKey = 'browser' | 'security' | 'privacy';
5672    const [sortKey, setSortKey] = useState<SortKey | null>(null);
5673    const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
5674    const defaultDir: Record<SortKey, 'asc' | 'desc'> = {
5675      browser:  'asc',  // A → Z feels natural for names
5676      security: 'desc', // high score first
5677      privacy:  'desc', // high score first
5678    };
5679    const cycleSort = (key: SortKey) => {
5680      if (sortKey !== key) {
5681        // Inactive column → activate at its default direction.
5682        setSortKey(key);
5683        setSortDir(defaultDir[key]);
5684        return;
5685      }
5686      if (sortDir === defaultDir[key]) {
5687        // Active at default → flip to opposite.
5688        setSortDir(defaultDir[key] === 'asc' ? 'desc' : 'asc');
5689        return;
5690      }
5691      // Active at opposite → reset.
5692      setSortKey(null);
5693    };
5694    // Comparator. Reset (sortKey === null) falls back to the auto ranking:
5695    // installed first, then security desc, then privacy desc. Score
5696    // columns tiebreak with the other score so equal rows still order
5697    // deterministically.
5698    const list = [...BROWSERS].sort((a, b) => {
5699      if (sortKey === null) {
5700        const ia = installed.has(a.pkg) ? 0 : 1;
5701        const ib = installed.has(b.pkg) ? 0 : 1;
5702        if (ia !== ib) return ia - ib;
5703        const ds = securityScore(b) - securityScore(a);
5704        if (ds !== 0) return ds;
5705        return privacyScore(b) - privacyScore(a);
5706      }
5707      const sign = sortDir === 'desc' ? 1 : -1;
5708      if (sortKey === 'browser') {
5709        return a.name.localeCompare(b.name) * sign;
5710      }
5711      if (sortKey === 'security') {
5712        const ds = (securityScore(b) - securityScore(a)) * sign;
5713        if (ds !== 0) return ds;
5714        return privacyScore(b) - privacyScore(a);
5715      }
5716      // privacy
5717      const dp = (privacyScore(b) - privacyScore(a)) * sign;
5718      if (dp !== 0) return dp;
5719      return securityScore(b) - securityScore(a);
5720    });
5721    return (
5722      <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
5723        <Pressable style={modalStyles.backdrop} onPress={onClose}>
5724          <Pressable
5725            style={[modalStyles.sheetTall, editorStyles.pickerSheet]}
5726            onPress={() => {}}>
5727            <View style={editorStyles.pickerTitleRow}>
5728              <Text style={[modalStyles.title, editorStyles.pickerTitle]}>
5729                Select browser
5730              </Text>
5731              <Pressable
5732                onPress={() => setGradesInfoOpen(true)}
5733                hitSlop={8}
5734                accessibilityLabel="About the scores"
5735                style={({ pressed }) => [
5736                  editorStyles.pickerTitleInfo,
5737                  pressed && modalStyles.actionBtnPressed,
5738                ]}>
5739                <Ionicons
5740                  name="information-circle-outline"
5741                  size={18}
5742                  color={Palette.textMuted}
5743                />
5744              </Pressable>
5745            </View>
5746  
5747            {/* Sortable column headers. Tapping the active column flips
5748                direction; tapping a different column switches sort key and
5749                uses its default direction. Thin vertical separators
5750                between cells reinforce the table read. */}
5751            <View style={editorStyles.tableHeader}>
5752              <SortHeader
5753                label="Browser"
5754                active={sortKey === 'browser'}
5755                dir={sortDir}
5756                onPress={() => cycleSort('browser')}
5757                style={editorStyles.colBrowser}
5758              />
5759              <View style={editorStyles.colSeparator} />
5760              <SortHeader
5761                label="Sec"
5762                active={sortKey === 'security'}
5763                dir={sortDir}
5764                onPress={() => cycleSort('security')}
5765                style={editorStyles.colScore}
5766                icon="shield-half-outline"
5767              />
5768              <View style={editorStyles.colSeparator} />
5769              <SortHeader
5770                label="Priv"
5771                active={sortKey === 'privacy'}
5772                dir={sortDir}
5773                onPress={() => cycleSort('privacy')}
5774                style={editorStyles.colScore}
5775                icon="glasses-outline"
5776              />
5777            </View>
5778  
5779            <ScrollView
5780              style={editorStyles.pickerScrollFlex}
5781              // Native scrollbar — see PrivacyExplanationModal for why
5782              // the custom Animated.Value scrollbar was retired.
5783              showsVerticalScrollIndicator
5784              persistentScrollbar
5785              indicatorStyle="white"
5786              nestedScrollEnabled>
5787              {list.length === 0 ? (
5788                <Text style={styles.emptyHint}>— No installed browsers —</Text>
5789              ) : (
5790                list.map((b) => {
5791                  const notInstalled = !installed.has(b.pkg);
5792                  // "Featured" = any browser we accent on a score axis.
5793                  // Independent of the text label, which only Brave carries
5794                  // now — so Tor / IronFox / Vanadium still get the lifted
5795                  // background + gold icons without inline copy.
5796                  const featured = !!SCORE_ACCENT[b.id];
5797                  return (
5798                  <View key={b.id} style={editorStyles.pickerRowWrap}>
5799                  <Pressable
5800                    onPress={() => setPending(b.pkg)}
5801                    onLongPress={() => setExplainBrowser(b)}
5802                    delayLongPress={350}
5803                    style={({ pressed }) => [
5804                      editorStyles.pickerRow,
5805                      featured && editorStyles.pickerRowFeatured,
5806                      b.pkg === pending && editorStyles.pickerRowSelected,
5807                      pressed && modalStyles.actionBtnPressed,
5808                    ]}>
5809                    <View style={[editorStyles.colBrowser, editorStyles.cellBrowser]}>
5810                      <View style={notInstalled ? editorStyles.pickerIconDim : null}>
5811                        {ASSET_ICONS[b.id] ? (
5812                          <Image source={ASSET_ICONS[b.id]} style={editorStyles.pickerIcon} />
5813                        ) : icons[b.pkg] ? (
5814                          <Image source={{ uri: icons[b.pkg] }} style={editorStyles.pickerIcon} />
5815                        ) : (
5816                          <View style={[editorStyles.pickerIcon, { backgroundColor: b.tint }]} />
5817                        )}
5818                      </View>
5819                      <View style={editorStyles.pickerNameCol}>
5820                        {/* Line 1: browser name + inline recommendation pill. */}
5821                        <View style={editorStyles.pickerNameRow}>
5822                          <Text
5823                            style={[
5824                              editorStyles.pickerName,
5825                              notInstalled && editorStyles.pickerNameDim,
5826                            ]}
5827                            numberOfLines={1}>
5828                            {formatRunicName(b.name, runicNames)}
5829                          </Text>
5830                          {BROWSER_RECOMMENDATION[b.id] ? (
5831                            <Text
5832                              style={[
5833                                editorStyles.recoInline,
5834                                notInstalled && editorStyles.recoInlineDim,
5835                              ]}
5836                              numberOfLines={1}>
5837                              {BROWSER_RECOMMENDATION[b.id]}
5838                            </Text>
5839                          ) : null}
5840                          {BROWSER_HAS_EXTENSIONS[b.id] ? (
5841                            <Ionicons
5842                              name="extension-puzzle-outline"
5843                              size={12}
5844                              color={Palette.textMuted}
5845                              style={[
5846                                editorStyles.extensionsIcon,
5847                                notInstalled && editorStyles.extensionsIconDim,
5848                              ]}
5849                            />
5850                          ) : null}
5851                        </View>
5852                        {/* Line 2: status / blurb. Always rendered (with
5853                            empty space when there's nothing to say) so the
5854                            row height stays constant whether or not the
5855                            browser is installed. */}
5856                        <Text
5857                          style={[
5858                            editorStyles.pickerSubline,
5859                            notInstalled && editorStyles.notInstalledTag,
5860                          ]}
5861                          numberOfLines={1}>
5862                          {notInstalled
5863                            ? BROWSER_AVAILABILITY[b.id]
5864                              ? `Not installed, ${BROWSER_AVAILABILITY[b.id].short}`
5865                              : 'Not installed'
5866                            : ' '}
5867                        </Text>
5868                      </View>
5869                    </View>
5870                    {/* Sec + Priv collapsed into one pill so the two
5871                        scores read as a single rating block. The pill's
5872                        total width matches two colScore cells so each
5873                        half still lines up under its sortable header.
5874                        Best-in-class browsers light up the matching
5875                        icon in gold (see SCORE_ACCENT). */}
5876                    {(() => {
5877                      const accent = SCORE_ACCENT[b.id];
5878                      return (
5879                        <View style={editorStyles.scorePill}>
5880                          <View style={editorStyles.scorePillHalf}>
5881                            <Ionicons
5882                              name={accent?.security ? 'shield-half' : 'shield-half-outline'}
5883                              size={11}
5884                              color={accent?.security ? GOLD : Palette.textMuted}
5885                              style={{ marginRight: 3 }}
5886                            />
5887                            <Text style={editorStyles.selectScoreText}>{securityScore(b)}</Text>
5888                          </View>
5889                          <View style={editorStyles.scorePillDivider} />
5890                          <View style={editorStyles.scorePillHalf}>
5891                            <Ionicons
5892                              name={accent?.privacy ? 'glasses' : 'glasses-outline'}
5893                              size={11}
5894                              color={accent?.privacy ? GOLD : Palette.textMuted}
5895                              style={{ marginRight: 3 }}
5896                            />
5897                            <Text style={editorStyles.selectScoreText}>{privacyScore(b)}</Text>
5898                          </View>
5899                        </View>
5900                      );
5901                    })()}
5902                  </Pressable>
5903                  {b.pkg === pending ? (
5904                    <View
5905                      style={editorStyles.pickerRowBottomCover}
5906                      pointerEvents="none"
5907                    />
5908                  ) : null}
5909                  </View>
5910                  );
5911                })
5912              )}
5913            </ScrollView>
5914            {/* Warning slot is always rendered (with a non-breaking space
5915                when there's nothing to say) so the footer below doesn't
5916                jump when toggling between installed and not-installed
5917                selections. */}
5918            <Text
5919              style={[
5920                editorStyles.pickerWarning,
5921                !(pending && !installed.has(pending)) && editorStyles.pickerWarningHidden,
5922              ]}>
5923              {pending && !installed.has(pending)
5924                ? 'This browser is not installed — install it first to continue.'
5925                : ' '}
5926            </Text>
5927            <View style={editorStyles.pickerFooter}>
5928              <Pressable
5929                onPress={onClose}
5930                style={({ pressed }) => [
5931                  editorStyles.cancelBtn,
5932                  pressed && modalStyles.actionBtnPressed,
5933                ]}>
5934                <Text style={modalStyles.closeBtnText}>Cancel</Text>
5935              </Pressable>
5936              {pending && installed.has(pending) ? (
5937                <Pressable
5938                  onPress={() => onPick(pending)}
5939                  style={({ pressed }) => [
5940                    editorStyles.saveBtn,
5941                    pressed && modalStyles.actionBtnPressed,
5942                  ]}>
5943                  <Text style={editorStyles.saveBtnText}>Continue</Text>
5944                </Pressable>
5945              ) : null}
5946              {/* Centered info button — its wrapper spans the footer width
5947                  via absolute positioning, but the Pressable itself is
5948                  intrinsic-width and centered by the wrapper's flex. So
5949                  it lands at the footer midpoint regardless of whether
5950                  Continue is rendered on the right. Long-press on a row
5951                  remains the gesture shortcut; this is the visible
5952                  affordance. The wrapper sits in the JSX *after* Continue
5953                  so taps on the overlapping center area hit Show info
5954                  first (RN gives later-painted siblings hit priority). */}
5955              {pending ? (
5956                <View style={editorStyles.showInfoCenter} pointerEvents="box-none">
5957                  <Pressable
5958                    onPress={() => {
5959                      const b = BROWSERS.find((x) => x.pkg === pending);
5960                      if (b) setExplainBrowser(b);
5961                    }}
5962                    style={({ pressed }) => [
5963                      editorStyles.showInfoBtn,
5964                      pressed && modalStyles.actionBtnPressed,
5965                    ]}>
5966                    <Ionicons
5967                      name="information-circle-outline"
5968                      size={14}
5969                      color={Palette.accentBright}
5970                    />
5971                    <Text style={editorStyles.showInfoBtnText}>Show info</Text>
5972                  </Pressable>
5973                </View>
5974              ) : null}
5975            </View>
5976          </Pressable>
5977        </Pressable>
5978  
5979        {/* Long-press opens the encyclopedia in info-only mode — selection
5980            lives on the row tap + Continue button, so the Select pill in
5981            the explanation header is suppressed here. */}
5982        <PrivacyExplanationModal
5983          browser={explainBrowser}
5984          canSelect={false}
5985          list={list}
5986          onNavigate={setExplainBrowser}
5987          onClose={() => setExplainBrowser(null)}
5988        />
5989  
5990        <InfoModal
5991          visible={gradesInfoOpen}
5992          title="About the scores"
5993          icon="ribbon-outline"
5994          body={
5995            'Security and Privacy are graded out of 100 from the best '
5996            + 'public information we have: privacytests.org test passes, '
5997            + "each browser's documented protections, and the strength of "
5998            + 'the private-mode intent it actually honours. They are '
5999            + 'opinions — useful for comparison, not a substitute for your '
6000            + 'own threat model. Open a browser to read the full breakdown.'
6001          }
6002          onClose={() => setGradesInfoOpen(false)}
6003        />
6004      </Modal>
6005    );
6006  }
6007  
6008  /**
6009   * Picker for a new extras-list entry. Curated documented intent extras
6010   * filtered to the active browser family + a Custom… escape hatch.
6011   */
6012  function ExtraPicker({
6013    visible, suggestions, maxPrivacyPreview, maxPrivacyAlreadySet,
6014    hasReferrerOverride,
6015    onSetMaxPrivacy, onPickSuggestion, onPickCustom, onClose,
6016  }: {
6017    visible: boolean;
6018    suggestions: ExtraSuggestion[];
6019    /** The extras the "Set max privacy" recipe will apply — shown to the
6020     *  user in a confirmation modal before any changes are made. */
6021    maxPrivacyPreview: FlowExtra[];
6022    /** When true, the "Set max privacy" row is hidden — the Flow already
6023     *  carries every canonical key, so re-applying would be a no-op. */
6024    maxPrivacyAlreadySet: boolean;
6025    /** Whether the current Flow carries a non-empty EXTRA_REFERRER override.
6026     *  When true, the max-privacy preview surfaces an extra row noting the
6027     *  override will be unset (so default Uri.EMPTY strip applies). */
6028    hasReferrerOverride: boolean;
6029    onSetMaxPrivacy: () => void;
6030    onPickSuggestion: (s: ExtraSuggestion) => void;
6031    onPickCustom: () => void;
6032    onClose: () => void;
6033  }) {
6034    const [confirmMax, setConfirmMax] = useState(false);
6035    return (
6036      <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
6037        <Pressable style={modalStyles.backdrop} onPress={onClose}>
6038          <Pressable style={modalStyles.sheet} onPress={() => {}}>
6039            <Text style={modalStyles.title}>Add extra</Text>
6040            <ScrollView style={editorStyles.pickerScroll}>
6041              {/* One-tap recipe: drops the canonical incognito +
6042                  ephemeral-CCT + referrer-override extras for the active
6043                  browser. Hidden when the Flow already carries the whole
6044                  recipe — re-applying would be a no-op. */}
6045              {!maxPrivacyAlreadySet ? (
6046                <>
6047                  <Pressable
6048                    onPress={() => setConfirmMax(true)}
6049                    style={({ pressed }) => [
6050                      editorStyles.maxPrivacyRow,
6051                      pressed && modalStyles.actionBtnPressed,
6052                    ]}>
6053                    <Ionicons name="shield-checkmark" size={14} color={Palette.text} />
6054                    <Text style={editorStyles.maxPrivacyRowLabel}>Set max privacy</Text>
6055                    <Ionicons name="chevron-forward" size={12} color={Palette.text} />
6056                  </Pressable>
6057                  <View style={editorStyles.pickerDivider} />
6058                </>
6059              ) : null}
6060  
6061              {suggestions.map((s) => (
6062                <Pressable
6063                  key={s.key}
6064                  onPress={() => onPickSuggestion(s)}
6065                  style={({ pressed }) => [
6066                    editorStyles.suggestionRow,
6067                    pressed && modalStyles.actionBtnPressed,
6068                  ]}>
6069                  <Text style={editorStyles.suggestionLabel}>{s.label}</Text>
6070                  <Text style={editorStyles.suggestionKey} numberOfLines={1}>
6071                    {s.key}
6072                  </Text>
6073                  {s.hint ? (
6074                    <Text style={editorStyles.suggestionHint}>{s.hint}</Text>
6075                  ) : null}
6076                </Pressable>
6077              ))}
6078  
6079              <Pressable
6080                onPress={onPickCustom}
6081                style={({ pressed }) => [
6082                  editorStyles.suggestionRow,
6083                  editorStyles.customRow,
6084                  pressed && modalStyles.actionBtnPressed,
6085                ]}>
6086                <Text style={editorStyles.suggestionLabel}>Custom…</Text>
6087                <Text style={editorStyles.suggestionHint}>Type any key + value</Text>
6088              </Pressable>
6089            </ScrollView>
6090            <Pressable
6091              onPress={onClose}
6092              style={({ pressed }) => [
6093                modalStyles.closeBtn,
6094                pressed && modalStyles.actionBtnPressed,
6095              ]}>
6096              <Text style={modalStyles.closeBtnText}>Cancel</Text>
6097            </Pressable>
6098          </Pressable>
6099        </Pressable>
6100  
6101        {/* Confirmation modal — stacks on top of the picker, lists the
6102            exact key/type/value triples the recipe will write. */}
6103        <Modal
6104          visible={confirmMax}
6105          transparent
6106          animationType="fade"
6107          onRequestClose={() => setConfirmMax(false)}>
6108          <Pressable
6109            style={modalStyles.backdrop}
6110            onPress={() => setConfirmMax(false)}>
6111            <Pressable style={modalStyles.sheet} onPress={() => {}}>
6112              <View style={modalStyles.header}>
6113                <View
6114                  style={[
6115                    modalStyles.headerIcon,
6116                    { alignItems: 'center', justifyContent: 'center', backgroundColor: Palette.accent },
6117                  ]}>
6118                  <Ionicons name="shield-checkmark" size={22} color={Palette.text} />
6119                </View>
6120                <View style={modalStyles.headerText}>
6121                  <Text style={modalStyles.title}>Set max privacy</Text>
6122                  <Text style={modalStyles.subtitle}>
6123                    {maxPrivacyPreview.length} extra
6124                    {maxPrivacyPreview.length === 1 ? '' : 's'} will be applied
6125                  </Text>
6126                </View>
6127              </View>
6128              <Text style={modalStyles.summary}>
6129                These intent extras will be applied. Any existing entries with
6130                the same keys are overridden, and duplicates are removed.
6131                Other custom extras you've added remain untouched.
6132              </Text>
6133              <ScrollView style={editorStyles.maxPrivacyPreviewScroll}>
6134                {maxPrivacyPreview.length === 0 ? (
6135                  <Text style={editorStyles.extrasEmpty}>
6136                    This browser has no canonical max-privacy recipe.
6137                  </Text>
6138                ) : (
6139                  maxPrivacyPreview.map((e, i) => {
6140                    const doc = lookupSuggestion(e.key);
6141                    return (
6142                      <View key={i} style={editorStyles.previewRow}>
6143                        <Text style={editorStyles.previewKey} numberOfLines={2}>
6144                          {e.key}
6145                        </Text>
6146                        <View style={editorStyles.previewMeta}>
6147                          <Text style={editorStyles.previewType}>{e.type}</Text>
6148                          <Text style={editorStyles.previewValue}>
6149                            = {e.value === '' ? '""' : e.value}
6150                          </Text>
6151                        </View>
6152                        {doc?.label ? (
6153                          <Text style={editorStyles.previewLabel}>{doc.label}</Text>
6154                        ) : null}
6155                      </View>
6156                    );
6157                  })
6158                )}
6159                {/* When the Flow has a non-empty referrer override, max
6160                    privacy will clear it so the default Uri.EMPTY strip
6161                    applies. Surface that as a preview row so the user
6162                    sees the change before tapping Apply. */}
6163                {hasReferrerOverride ? (
6164                  <View style={[editorStyles.previewRow, editorStyles.previewRowUnset]}>
6165                    <Text style={editorStyles.previewKey} numberOfLines={2}>
6166                      {REFERRER_EXTRA_KEY}
6167                    </Text>
6168                    <View style={editorStyles.previewMeta}>
6169                      <Text style={editorStyles.previewType}>unset</Text>
6170                      <Text style={editorStyles.previewValue}>
6171                        = stripped (Uri.EMPTY)
6172                      </Text>
6173                    </View>
6174                    <Text style={editorStyles.previewLabel}>
6175                      Referrer override removed — the app auto-strips referrer.
6176                    </Text>
6177                  </View>
6178                ) : null}
6179              </ScrollView>
6180              <View style={editorStyles.footer}>
6181                <View style={{ flex: 1 }} />
6182                <Pressable
6183                  onPress={() => setConfirmMax(false)}
6184                  style={({ pressed }) => [
6185                    editorStyles.cancelBtn,
6186                    pressed && modalStyles.actionBtnPressed,
6187                  ]}>
6188                  <Text style={modalStyles.closeBtnText}>Cancel</Text>
6189                </Pressable>
6190                <Pressable
6191                  onPress={() => {
6192                    setConfirmMax(false);
6193                    onSetMaxPrivacy();
6194                  }}
6195                  disabled={maxPrivacyPreview.length === 0}
6196                  style={({ pressed }) => [
6197                    editorStyles.saveBtn,
6198                    maxPrivacyPreview.length === 0 && { opacity: 0.4 },
6199                    pressed && modalStyles.actionBtnPressed,
6200                  ]}>
6201                  <Text style={editorStyles.saveBtnText}>Apply</Text>
6202                </Pressable>
6203              </View>
6204            </Pressable>
6205          </Pressable>
6206        </Modal>
6207      </Modal>
6208    );
6209  }
6210  
6211  const editorStyles = StyleSheet.create({
6212    editorHeader: {
6213      flexDirection: 'row',
6214      alignItems: 'center',
6215      gap: 10,
6216      paddingBottom: 10,
6217      marginBottom: 4,
6218      borderBottomWidth: 1,
6219      borderBottomColor: Palette.border,
6220    },
6221    editorHeaderIconWrap: {
6222      padding: 2,
6223      borderRadius: 12,
6224    },
6225    editorHeaderIcon: {
6226      width: 40,
6227      height: 40,
6228      borderRadius: 10,
6229      backgroundColor: Palette.bgElevated,
6230    },
6231    editorHeaderText: { flex: 1 },
6232    editorTitle: { fontSize: 15, color: Palette.text, fontWeight: '700' },
6233    editorSubtitle: {
6234      fontSize: 11,
6235      color: Palette.textMuted,
6236      marginTop: 1,
6237      letterSpacing: 0.2,
6238    },
6239    editorClose: {
6240      width: 28, height: 28,
6241      alignItems: 'center', justifyContent: 'center',
6242      borderRadius: 14,
6243    },
6244    // Form body — consistent vertical rhythm via `gap`; each section is
6245    // a self-contained card so individual margins are unnecessary.
6246    formBody: { paddingVertical: 6, paddingHorizontal: 2, gap: 10 },
6247    fieldLabel: {
6248      fontSize: 9,
6249      color: Palette.textMuted,
6250      fontWeight: '700',
6251      letterSpacing: 1,
6252      textTransform: 'uppercase',
6253      marginTop: 10,
6254      marginBottom: 4,
6255      paddingHorizontal: 2,
6256    },
6257    // Single clickable pill that combines the info icon + privacy/security
6258    // scores. Tapping it opens the Privacy Features modal for the browser.
6259    // Subtle border + elevated background so it reads as one cohesive
6260    // affordance rather than a row of small icons.
6261    infoStatsPill: {
6262      flexDirection: 'row',
6263      alignItems: 'center',
6264      paddingLeft: 8,
6265      paddingRight: 9,
6266      paddingVertical: 5,
6267      borderRadius: 999,
6268      backgroundColor: Palette.bgElevated,
6269      borderWidth: 1,
6270      borderColor: Palette.border,
6271    },
6272    infoStatsDivider: {
6273      width: 1,
6274      height: 12,
6275      backgroundColor: Palette.border,
6276      marginHorizontal: 7,
6277    },
6278    infoStatsScore: {
6279      flexDirection: 'row',
6280      alignItems: 'center',
6281      gap: 4,
6282    },
6283    // Plain (non-pressable) version of the score readout shown on the
6284    // right of each picker row. The whole row is now the tappable
6285    // surface, so the scores no longer need pill chrome — just two
6286    // glyph + number pairs with a thin divider.
6287    // ── BrowserPicker table layout ───────────────────────────────────
6288    // Columns share fixed widths between header and body rows so they
6289    // visually line up. Browser column flexes; the rest are fixed-width
6290    // numeric/status columns.
6291    // Header reads as the top edge of a card table: full border with
6292    // matching rounded corners on top, flat bottom so the row cards
6293    // below feel "stitched" to it. Slightly elevated bg sets it apart
6294    // as chrome without breaking visual connection to the body.
6295    tableHeader: {
6296      flexDirection: 'row',
6297      alignItems: 'center',
6298      paddingVertical: 7,
6299      paddingHorizontal: 8,
6300      marginTop: 8,
6301      backgroundColor: Palette.bgElevated,
6302      borderTopWidth: 1,
6303      borderLeftWidth: 1,
6304      borderRightWidth: 1,
6305      borderBottomWidth: 1,
6306      borderColor: Palette.border,
6307      borderTopLeftRadius: 12,
6308      borderTopRightRadius: 12,
6309    },
6310    tableHeaderText: {
6311      fontSize: 10,
6312      color: Palette.textMuted,
6313      fontWeight: '700',
6314      letterSpacing: 0.5,
6315      textTransform: 'uppercase',
6316    },
6317    tableHeaderTextActive: { color: Palette.accentBright },
6318    sortHeader: {
6319      flexDirection: 'row',
6320      alignItems: 'center',
6321      justifyContent: 'center',
6322    },
6323    colBrowser: { flex: 1, minWidth: 0 },
6324    colScore: { width: 56 },
6325    // Combined sec + priv pill rendered in the row body. Width matches
6326    // two colScore cells so each half lines up under its sortable header.
6327    scorePill: {
6328      flexDirection: 'row',
6329      alignItems: 'center',
6330      // 56 (Sec col) + 9 (header colSeparator) + 56 (Priv col) — keeps
6331      // each half of the pill lined up under its sortable header.
6332      width: 56 + 9 + 56,
6333      paddingVertical: 4,
6334      borderRadius: 999,
6335      backgroundColor: Palette.bgElevated,
6336      borderWidth: 1,
6337      borderColor: Palette.border,
6338    },
6339    scorePillHalf: {
6340      flex: 1,
6341      flexDirection: 'row',
6342      alignItems: 'center',
6343      justifyContent: 'center',
6344    },
6345    scorePillDivider: {
6346      width: 1,
6347      height: 12,
6348      backgroundColor: Palette.border,
6349    },
6350    // Thin vertical divider rendered between header cells. Body rows
6351    // don't repeat it — the divider line at the bottom of the header
6352    // does enough work to anchor the column boundaries.
6353    colSeparator: {
6354      width: 1,
6355      alignSelf: 'stretch',
6356      backgroundColor: Palette.border,
6357      marginHorizontal: 4,
6358    },
6359    // The Browser sort header sits flush-left so its label lines up with
6360    // the icon column below — overrides the default centered alignment
6361    // used by the score headers.
6362    cellBrowser: {
6363      flexDirection: 'row',
6364      alignItems: 'center',
6365      gap: 10,
6366    },
6367    cellCenter: {
6368      flexDirection: 'row',
6369      alignItems: 'center',
6370      justifyContent: 'center',
6371    },
6372    selectScoreText: {
6373      fontSize: 11,
6374      color: Palette.text,
6375      fontWeight: '700',
6376      fontVariant: ['tabular-nums'],
6377    },
6378    input: {
6379      fontSize: 13,
6380      color: Palette.text,
6381      paddingVertical: 8,
6382      paddingHorizontal: 10,
6383      borderRadius: 9,
6384      borderWidth: 1,
6385      borderColor: Palette.border,
6386      backgroundColor: Palette.bgElevated,
6387    },
6388  
6389    // Single-row "labeled input" card — icon + bold label + input on the
6390    // right, flexed to fill the rest. Matches autoFireRow's paddings,
6391    // gap, radius, border, and background so the Tag and Autolaunch
6392    // cards read as a matched pair at the top of the editor.
6393    fieldCard: {
6394      flexDirection: 'row',
6395      alignItems: 'center',
6396      gap: 8,
6397      paddingVertical: 8,
6398      paddingHorizontal: 12,
6399      borderRadius: 11,
6400      borderWidth: 1,
6401      borderColor: Palette.border,
6402      backgroundColor: Palette.bgElevated,
6403    },
6404    fieldCardInput: {
6405      flex: 1,
6406      fontSize: 13,
6407      color: Palette.text,
6408      paddingVertical: 0,
6409      paddingHorizontal: 0,
6410      textAlign: 'right',
6411      // Input sits flush inside the card — no extra chrome since the
6412      // card already supplies the bordered surface. Right-aligned so
6413      // the placeholder/value sits where the Autolaunch switch sits in
6414      // its sibling card, completing the visual pairing.
6415    },
6416  
6417    // Auto-fire card — single line: flash icon + label + (i) doc
6418    // + iOS-style switch. Tight vertical padding so it matches the
6419    // height of the Tag card above.
6420    autoFireRow: {
6421      flexDirection: 'row',
6422      alignItems: 'center',
6423      gap: 8,
6424      paddingVertical: 8,
6425      paddingHorizontal: 12,
6426      borderRadius: 11,
6427      borderWidth: 1,
6428      borderColor: Palette.border,
6429      backgroundColor: Palette.bgElevated,
6430    },
6431    autoFireRowOn: { borderColor: Palette.highlight, backgroundColor: Palette.surface },
6432    autoFireTitle: {
6433      fontSize: 13,
6434      color: Palette.text,
6435      fontWeight: '700',
6436      letterSpacing: 0.2,
6437    },
6438    autoFireInfo: {
6439      width: 22, height: 22,
6440      alignItems: 'center', justifyContent: 'center',
6441      borderRadius: 11,
6442    },
6443    switchTrack: {
6444      width: 34,
6445      height: 18,
6446      borderRadius: 999,
6447      backgroundColor: Palette.bgElevated,
6448      borderWidth: 1,
6449      borderColor: Palette.border,
6450      padding: 2,
6451      justifyContent: 'center',
6452    },
6453    switchTrackOn: {
6454      backgroundColor: Palette.accent,
6455      borderColor: Palette.accentBright,
6456    },
6457    switchThumb: {
6458      width: 12,
6459      height: 12,
6460      borderRadius: 999,
6461      backgroundColor: Palette.textMuted,
6462    },
6463    switchThumbOn: {
6464      backgroundColor: Palette.highlight,
6465      transform: [{ translateX: 15 }],
6466    },
6467  
6468  
6469    // Inner table — sits inside the panel's bordered card, no border of its own.
6470    extrasTable: {
6471      backgroundColor: Palette.bgElevated,
6472    },
6473    extrasEmpty: {
6474      fontSize: 11,
6475      color: Palette.textMuted,
6476      fontStyle: 'italic',
6477      paddingVertical: 7,
6478      paddingHorizontal: 10,
6479    },
6480    extraRow: {
6481      flexDirection: 'row',
6482      alignItems: 'center',
6483      paddingVertical: 6,
6484      paddingHorizontal: 7,
6485      borderBottomWidth: 1,
6486      borderBottomColor: Palette.border,
6487      gap: 6,
6488    },
6489    extraFields: { flex: 1, gap: 4 },
6490    extraKeyRow: { flexDirection: 'row', alignItems: 'center', gap: 4 },
6491    extraDocBtn: {
6492      width: 22, height: 22,
6493      alignItems: 'center', justifyContent: 'center',
6494      borderRadius: 11,
6495    },
6496    extraKey: {
6497      // Keys like com.google.android.apps.chrome.EXTRA_OPEN_NEW_INCOGNITO_TAB
6498      // are long — keep the font small enough that a typical key fits on
6499      // one line inside the field.
6500      fontSize: 9,
6501      color: Palette.text,
6502      fontFamily: Fonts?.mono,
6503      paddingVertical: 4,
6504      paddingHorizontal: 6,
6505      borderRadius: 6,
6506      borderWidth: 1,
6507      borderColor: Palette.border,
6508      backgroundColor: Palette.surface,
6509    },
6510    extraValueRow: { flexDirection: 'row', gap: 4, alignItems: 'center' },
6511    // Pill variant of the value field for bool/enum keys: shows the
6512    // current label, tap to cycle through the allowed values.
6513    extraValueDropdown: {
6514      flexDirection: 'row',
6515      alignItems: 'center',
6516      justifyContent: 'space-between',
6517      paddingVertical: 5,
6518      paddingRight: 6,
6519    },
6520    extraValueDropdownText: {
6521      fontSize: 11,
6522      color: Palette.text,
6523      fontFamily: Fonts?.mono,
6524      fontWeight: '600',
6525    },
6526    typeChip: {
6527      paddingVertical: 4,
6528      paddingHorizontal: 8,
6529      borderRadius: 6,
6530      borderWidth: 1,
6531      borderColor: Palette.accentBright,
6532      backgroundColor: Palette.bg,
6533    },
6534    typeChipText: {
6535      fontSize: 10,
6536      color: Palette.accentBright,
6537      fontWeight: '700',
6538      letterSpacing: 0.5,
6539      textTransform: 'uppercase',
6540    },
6541    extraValue: {
6542      flex: 1,
6543      fontSize: 11,
6544      color: Palette.text,
6545      fontFamily: Fonts?.mono,
6546      paddingVertical: 4,
6547      paddingHorizontal: 6,
6548      borderRadius: 6,
6549      borderWidth: 1,
6550      borderColor: Palette.border,
6551      backgroundColor: Palette.surface,
6552    },
6553    extraRemove: {
6554      width: 24, height: 24,
6555      alignItems: 'center', justifyContent: 'center',
6556      borderRadius: 12,
6557    },
6558    // Extras / Behaviour subpanel — cohesive bordered card with a title
6559    // bar (label + Add-new chip) above the entries table. Margin
6560    // handled by formBody's gap.
6561    extrasPanel: {
6562      borderRadius: 11,
6563      borderWidth: 1,
6564      borderColor: Palette.border,
6565      backgroundColor: Palette.surfaceDim,
6566      overflow: 'hidden',
6567    },
6568    extrasPanelHeader: {
6569      flexDirection: 'row',
6570      alignItems: 'center',
6571      justifyContent: 'space-between',
6572      paddingVertical: 8,
6573      paddingHorizontal: 12,
6574      borderBottomWidth: 1,
6575      borderBottomColor: Palette.border,
6576      backgroundColor: Palette.surface,
6577    },
6578    extrasPanelTitle: {
6579      fontSize: 11,
6580      color: Palette.text,
6581      fontWeight: '700',
6582      letterSpacing: 0.6,
6583      textTransform: 'uppercase',
6584    },
6585    addNewChip: {
6586      flexDirection: 'row',
6587      alignItems: 'center',
6588      gap: 4,
6589      paddingHorizontal: 8,
6590      paddingVertical: 4,
6591      borderRadius: 999,
6592      borderWidth: 1,
6593      borderColor: Palette.highlight,
6594      backgroundColor: Palette.bgElevated,
6595    },
6596    addNewChipText: {
6597      fontSize: 10,
6598      color: Palette.highlight,
6599      fontWeight: '700',
6600      letterSpacing: 0.5,
6601      textTransform: 'uppercase',
6602    },
6603    // Top-of-picker one-tap row that drops the canonical max-privacy
6604    // extras. Compact single line, a bit of breathing room above + below
6605    // so it sits as a deliberate accent row rather than crowding the
6606    // picker title and the curated list below.
6607    maxPrivacyRow: {
6608      flexDirection: 'row',
6609      alignItems: 'center',
6610      gap: 10,
6611      paddingVertical: 10,
6612      paddingHorizontal: 14,
6613      borderRadius: 999,
6614      borderWidth: 1,
6615      borderColor: Palette.accentBright,
6616      backgroundColor: Palette.accent,
6617      marginTop: 6,
6618      marginBottom: 6,
6619      marginHorizontal: 2,
6620    },
6621    maxPrivacyRowLabel: {
6622      flex: 1,
6623      fontSize: 12,
6624      color: Palette.text,
6625      fontWeight: '700',
6626      letterSpacing: 0.3,
6627    },
6628    pickerDivider: {
6629      height: 1,
6630      backgroundColor: Palette.border,
6631      marginVertical: 10,
6632      marginHorizontal: 4,
6633    },
6634    // Max-privacy confirmation preview list.
6635    maxPrivacyPreviewScroll: {
6636      maxHeight: 280,
6637      marginTop: 8,
6638      marginBottom: 4,
6639      borderTopWidth: 1,
6640      borderBottomWidth: 1,
6641      borderColor: Palette.border,
6642      backgroundColor: Palette.bgElevated,
6643    },
6644    previewRow: {
6645      paddingVertical: 8,
6646      paddingHorizontal: 10,
6647      borderBottomWidth: 1,
6648      borderBottomColor: Palette.border,
6649    },
6650    // Variant for the "this row will be removed" preview — muted
6651    // background + struck-through key so the destructive nature is
6652    // visible at a glance.
6653    previewRowUnset: {
6654      backgroundColor: 'rgba(200,112,112,0.06)',
6655    },
6656    previewKey: {
6657      fontSize: 10,
6658      fontFamily: Fonts?.mono,
6659      color: Palette.text,
6660      lineHeight: 14,
6661    },
6662    previewMeta: {
6663      flexDirection: 'row',
6664      alignItems: 'baseline',
6665      gap: 4,
6666      marginTop: 2,
6667    },
6668    previewType: {
6669      fontSize: 9,
6670      color: Palette.accentBright,
6671      fontWeight: '700',
6672      letterSpacing: 0.5,
6673      textTransform: 'uppercase',
6674    },
6675    previewValue: {
6676      fontSize: 11,
6677      fontFamily: Fonts?.mono,
6678      color: Palette.highlight,
6679    },
6680    previewLabel: {
6681      fontSize: 10,
6682      color: Palette.textMuted,
6683      marginTop: 2,
6684    },
6685  
6686    footer: {
6687      flexDirection: 'row',
6688      alignItems: 'center',
6689      gap: 6,
6690      marginTop: 10,
6691    },
6692    deleteBtn: {
6693      flexDirection: 'row',
6694      alignItems: 'center',
6695      gap: 4,
6696      paddingVertical: 9,
6697      paddingHorizontal: 11,
6698      borderRadius: 9,
6699      borderWidth: 1,
6700      borderColor: '#8a3a3a',
6701      backgroundColor: '#2a1414',
6702      marginRight: 'auto',
6703    },
6704    deleteBtnText: { fontSize: 12, color: '#f0a0a0', fontWeight: '600' },
6705    // Compact icon-only reorder buttons in the editor footer — mirror
6706    // the cancelBtn chrome so they slot between Delete and Cancel
6707    // without claiming label space.
6708    moveBtn: {
6709      width: 36,
6710      height: 36,
6711      alignItems: 'center',
6712      justifyContent: 'center',
6713      borderRadius: 9,
6714      borderWidth: 1,
6715      borderColor: Palette.border,
6716      backgroundColor: Palette.bg,
6717    },
6718    moveBtnDisabled: { opacity: 0.4 },
6719    cancelBtn: {
6720      paddingVertical: 9,
6721      paddingHorizontal: 14,
6722      borderRadius: 9,
6723      borderWidth: 1,
6724      borderColor: Palette.border,
6725      backgroundColor: Palette.bg,
6726    },
6727    // BackPill variant: cancel-btn chrome + row layout so the chevron
6728    // and label sit side by side.
6729    backPill: {
6730      flexDirection: 'row',
6731      alignItems: 'center',
6732      paddingLeft: 10,
6733    },
6734    saveBtn: {
6735      paddingVertical: 9,
6736      paddingHorizontal: 18,
6737      borderRadius: 9,
6738      borderWidth: 1,
6739      borderColor: Palette.accentBright,
6740      backgroundColor: Palette.accent,
6741    },
6742    saveBtnText: { fontSize: 13, color: Palette.text, fontWeight: '700' },
6743  
6744    pickerScroll: { maxHeight: 420 },
6745    // BrowserPicker variant — used inside `pickerScrollWrap` (a flex
6746    // row), so it needs `flex: 1` to claim the remaining width next to
6747    // the scrollbar track. ExtraPicker keeps the plain `pickerScroll`
6748    // because its ScrollView is a direct child of the sheet (no row
6749    // wrapper), where `flex: 1` would collapse it to nothing.
6750    // No flex — inside the (now-bounded) sheetTall, flex:1 would
6751    // collapse to 0 because the column's sibling rows (title, table
6752    // header, footer) are all content-sized. maxHeight caps the scroll
6753    // region; content overflow becomes scrollable.
6754    pickerScrollFlex: { maxHeight: 480 },
6755    // Wrapper that lays the custom scrollbar track + the ScrollView side
6756    // by side. No top margin — the row cards now visually attach to the
6757    // header below.
6758    pickerScrollWrap: {
6759      flexDirection: 'row',
6760      maxHeight: 420,
6761      marginTop: 2,
6762    },
6763    // Scrollbar styled to match the table header chrome — track sits in
6764    // the same elevated-surface tone as the headers above, so the
6765    // affordance feels like a continuation of the same panel. The thumb
6766    // is muted forest green, present enough to mark scroll position
6767    // without competing with the rows.
6768    scrollTrack: {
6769      width: 5,
6770      marginRight: 8,
6771      marginTop: 4,
6772      marginBottom: 4,
6773      borderRadius: 3,
6774      backgroundColor: Palette.bgElevated,
6775      overflow: 'hidden',
6776    },
6777    scrollThumb: {
6778      width: 5,
6779      borderRadius: 3,
6780      backgroundColor: Palette.accentDeep,
6781    },
6782    // Sheet + title overrides for the BrowserPicker — tighter padding
6783    // than the default modal sheet, centered title to match the rest of
6784    // the table's centered columns.
6785    pickerSheet: { padding: 12 },
6786    pickerTitle: { textAlign: 'center' },
6787    // Title + info icon centered as one group, the icon snug against
6788    // the title so it reads as an annotation of the heading rather
6789    // than a chrome-corner action.
6790    pickerTitleRow: {
6791      flexDirection: 'row',
6792      alignItems: 'center',
6793      justifyContent: 'center',
6794      gap: 6,
6795    },
6796    pickerTitleInfo: {
6797      width: 22,
6798      height: 22,
6799      alignItems: 'center',
6800      justifyContent: 'center',
6801      borderRadius: 11,
6802      // Title font's visual center sits below the line-box center
6803      // (more ascender than descender at this weight), so the icon
6804      // looks slightly high when box-centered. Nudge it down by 3px
6805      // to land on the title's visual centerline.
6806      marginTop: 3,
6807    },
6808    pickerFooter: {
6809      flexDirection: 'row',
6810      alignItems: 'center',
6811      justifyContent: 'space-between',
6812      gap: 8,
6813      marginTop: 12,
6814      position: 'relative',
6815    },
6816    // Full-width absolute wrapper that centers its intrinsic-width child
6817    // (the actual Pressable). `alignItems: 'center'` on the default
6818    // column layout centers horizontally; `justifyContent: 'center'`
6819    // centers vertically inside the footer's height.
6820    // pointerEvents="box-none" on the wrapper means it doesn't
6821    // intercept taps in the empty side areas — only the child Pressable
6822    // swallows touches.
6823    showInfoCenter: {
6824      position: 'absolute',
6825      left: 0,
6826      right: 0,
6827      top: 0,
6828      bottom: 0,
6829      alignItems: 'center',
6830      justifyContent: 'center',
6831    },
6832    showInfoBtn: {
6833      flexDirection: 'row',
6834      alignItems: 'center',
6835      gap: 5,
6836      paddingVertical: 8,
6837      paddingHorizontal: 12,
6838      borderRadius: 999,
6839      borderWidth: 1,
6840      borderColor: Palette.border,
6841      backgroundColor: Palette.bgElevated,
6842    },
6843    showInfoBtnText: {
6844      fontSize: 12,
6845      color: Palette.accentBright,
6846      fontWeight: '600',
6847      letterSpacing: 0.2,
6848    },
6849    // Card-style rows: rounded corners, visible border, slight breathing
6850    // room between cards. Selected rows swap to the highlight border +
6851    // a faintly elevated background so the active pick reads at a glance.
6852    // Wrapper that hosts the Pressable + a sibling overlay used to repaint
6853    // the selected row's bottom edge. Sibling-not-child so the overlay
6854    // isn't clipped or out-drawn by the Pressable's own border render.
6855    pickerRowWrap: {
6856      marginVertical: 2,
6857      position: 'relative',
6858    },
6859    pickerRow: {
6860      flexDirection: 'row',
6861      alignItems: 'center',
6862      gap: 10,
6863      paddingVertical: 7,
6864      paddingHorizontal: 8,
6865      borderRadius: 12,
6866      borderWidth: 1,
6867      borderColor: Palette.border,
6868      backgroundColor: Palette.bg,
6869    },
6870    // Featured rows (those with a BROWSER_RECOMMENDATION label) sit on a
6871    // lifted background tone so the recommended set reads as a quiet
6872    // band — works whether sorted by name, security, or privacy.
6873    pickerRowFeatured: {
6874      backgroundColor: Palette.bgElevated,
6875    },
6876    pickerRowSelected: {
6877      backgroundColor: Palette.bgElevated,
6878      borderColor: Palette.highlight,
6879    },
6880    // Strip placed across the selected row's bottom edge in the wrapper's
6881    // coordinate space (sibling of the Pressable). Width inset by the
6882    // row's borderRadius so the green rounded corners stay green; the
6883    // straight middle segment is overpainted in textMuted.
6884    pickerRowBottomCover: {
6885      position: 'absolute',
6886      left: 12,
6887      right: 12,
6888      bottom: 0,
6889      height: 1,
6890      backgroundColor: Palette.textMuted,
6891    },
6892    pickerIcon: { width: 26, height: 26, borderRadius: 6 },
6893    pickerIconDim: { opacity: 0.4 },
6894    pickerNameCol: { flex: 1, flexDirection: 'column', justifyContent: 'center' },
6895    pickerName: { fontSize: 13, color: Palette.text, fontWeight: '600' },
6896    pickerNameDim: { color: Palette.textMuted },
6897    notInstalledTag: {
6898      fontSize: 10,
6899      color: Palette.textMuted,
6900      fontStyle: 'italic',
6901      marginTop: 1,
6902    },
6903    pickerNameRow: {
6904      flexDirection: 'row',
6905      alignItems: 'baseline',
6906      flexWrap: 'wrap',
6907    },
6908    // Second line under the browser name. Always rendered (with a
6909    // single space when there's nothing meaningful to show) so the row
6910    // height stays identical between installed and not-installed entries.
6911    pickerSubline: {
6912      fontSize: 10,
6913      color: 'transparent',
6914      marginTop: 1,
6915      lineHeight: 13,
6916    },
6917    recoInline: {
6918      fontSize: 11,
6919      color: Palette.highlight,
6920      fontWeight: '600',
6921      marginLeft: 6,
6922      letterSpacing: 0.2,
6923    },
6924    recoInlineDim: { opacity: 0.45 },
6925    // Small puzzle glyph following the name (and any recommendation
6926    // pill) — marks browsers that support third-party extensions.
6927    // Informational only; the row tap/long-press behaviour is unchanged.
6928    extensionsIcon: { marginLeft: 6 },
6929    extensionsIconDim: { opacity: 0.45 },
6930    pickerWarning: {
6931      fontSize: 12,
6932      color: Palette.textMuted,
6933      fontStyle: 'italic',
6934      textAlign: 'center',
6935      marginTop: 8,
6936      lineHeight: 16,
6937    },
6938    // Transparent variant — keeps the same metrics (fontSize / lineHeight)
6939    // so the slot's vertical space stays reserved when there's no
6940    // warning to show, preventing the footer from jumping.
6941    pickerWarningHidden: { color: 'transparent' },
6942  
6943    suggestionRow: {
6944      paddingVertical: 9,
6945      paddingHorizontal: 10,
6946      borderRadius: 8,
6947      marginBottom: 4,
6948      borderWidth: 1,
6949      borderColor: Palette.border,
6950      backgroundColor: Palette.bgElevated,
6951    },
6952    // Custom row inherits the same chrome as suggestion rows — same
6953    // padding, radius, border, and elevated background. No extra
6954    // differentiation; the label "Custom…" + hint already announce
6955    // the escape hatch on their own.
6956    customRow: {},
6957    suggestionLabel: { fontSize: 13, color: Palette.text, fontWeight: '600' },
6958    suggestionKey: {
6959      fontSize: 10,
6960      color: Palette.accentBright,
6961      fontFamily: Fonts?.mono,
6962      marginTop: 1,
6963    },
6964    suggestionHint: {
6965      fontSize: 10,
6966      color: Palette.textMuted,
6967      lineHeight: 13,
6968      marginTop: 2,
6969    },
6970  
6971    // Profile picker in FlowEditorModal — segmented pill row inside the fieldCard
6972    profilePickerRow: {
6973      flex: 1,
6974      flexDirection: 'row',
6975      gap: 3,
6976      justifyContent: 'flex-end',
6977    },
6978    profilePickerBtn: {
6979      paddingVertical: 4,
6980      paddingHorizontal: 8,
6981      borderRadius: 7,
6982      borderWidth: 1,
6983      borderColor: Palette.border,
6984      backgroundColor: 'transparent',
6985    },
6986    profilePickerBtnPrivacy: {
6987      borderColor: Palette.accent,
6988      backgroundColor: Palette.accent + '22',
6989    },
6990    profilePickerBtnWork: {
6991      borderColor: '#f6c84c',
6992      backgroundColor: '#f6c84c' + '22',
6993    },
6994    profilePickerBtnRaw: {
6995      borderColor: Palette.textMuted,
6996      backgroundColor: Palette.textMuted + '15',
6997    },
6998    profilePickerBtnText: {
6999      fontSize: 11,
7000      fontWeight: '600',
7001      color: Palette.textMuted,
7002    },
7003    profilePickerBtnTextActive: {
7004      color: Palette.text,
7005    },
7006  });
7007  
7008  /**
7009   * Standalone glossary — every encyclopedia term grouped by axis
7010   * (Security / Privacy), each with its definition shown inline. Opened
7011   * from the "Glossary" button above the encyclopedia's tabs. Uses the
7012   * sibling-pattern modal chrome so the scroll behaves.
7013   */
7014  function GlossaryModal({ visible, onClose }: { visible: boolean; onClose: () => void }) {
7015    const { height: viewportH } = useWindowDimensions();
7016    const scrollMaxH = Math.max(280, Math.floor(viewportH * 0.6));
7017    const groups: { axis: 'security' | 'privacy'; label: string; icon: keyof typeof Ionicons.glyphMap }[] = [
7018      { axis: 'security', label: 'Security', icon: 'shield-half-outline' },
7019      { axis: 'privacy', label: 'Privacy', icon: 'glasses-outline' },
7020    ];
7021    return (
7022      <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
7023        <View style={modalStyles.backdrop} pointerEvents="box-none">
7024          <Pressable style={modalStyles.backdropFill} onPress={onClose} accessibilityLabel="Close glossary" />
7025          <View style={modalStyles.sheetTall}>
7026            <View style={modalStyles.headerSticky}>
7027              <View style={[modalStyles.headerIcon, modalStyles.glossaryHeaderIcon]}>
7028                <Ionicons name="book-outline" size={22} color={Palette.highlight} />
7029              </View>
7030              <View style={modalStyles.headerText}>
7031                <Text style={modalStyles.encTitle}>Glossary</Text>
7032                <Text style={modalStyles.encSubtitle}>Terms explained</Text>
7033              </View>
7034            </View>
7035            <ScrollView
7036              style={[modalStyles.explainScrollInWrap, { maxHeight: scrollMaxH }]}
7037              contentContainerStyle={modalStyles.explainScrollContent}
7038              showsVerticalScrollIndicator
7039              persistentScrollbar
7040              indicatorStyle="white"
7041              nestedScrollEnabled>
7042              {groups.map((g) => (
7043                <View key={g.axis} style={modalStyles.section}>
7044                  <View style={modalStyles.glossaryGroupHeader}>
7045                    <Ionicons name={g.icon} size={14} color={Palette.accentBright} />
7046                    <Text style={modalStyles.sectionTitle}>{g.label}</Text>
7047                  </View>
7048                  {glossaryFor(g.axis).map((entry) => (
7049                    <View key={entry.term} style={modalStyles.glossaryEntry}>
7050                      <Text style={modalStyles.glossaryTerm}>{entry.term}</Text>
7051                      <Text style={modalStyles.glossaryDef}>{entry.def}</Text>
7052                    </View>
7053                  ))}
7054                </View>
7055              ))}
7056            </ScrollView>
7057            <View style={modalStyles.explainCloseRow}>
7058              <View style={modalStyles.cycleBtnSpacer} />
7059              <Pressable
7060                onPress={onClose}
7061                style={({ pressed }) => [editorStyles.cancelBtn, pressed && modalStyles.actionBtnPressed]}>
7062                <Text style={modalStyles.closeBtnText}>Close</Text>
7063              </Pressable>
7064              <View style={modalStyles.cycleBtnSpacer} />
7065            </View>
7066          </View>
7067        </View>
7068      </Modal>
7069    );
7070  }
7071  
7072  /**
7073   * Configuration checklist — the recommended hardening settings for a
7074   * browser, rendered as a tickable to-do list. Check state is local
7075   * only for now (resets when the modal closes); persistence per
7076   * browser will come later.
7077   */
7078  function ConfigChecklistModal({
7079    browser,
7080    visible,
7081    onClose,
7082  }: {
7083    browser: Browser | null;
7084    visible: boolean;
7085    onClose: () => void;
7086  }) {
7087    const { height: viewportH } = useWindowDimensions();
7088    const scrollMaxH = Math.max(280, Math.floor(viewportH * 0.6));
7089    // Local checked state keyed by item text. Reset on open so the
7090    // dummy list always starts fresh (no persistence yet).
7091    const [checked, setChecked] = useState<Record<string, boolean>>({});
7092    useEffect(() => { if (visible) setChecked({}); }, [visible, browser?.id]);
7093    if (!browser) {
7094      return <Modal visible={false} transparent onRequestClose={onClose}><View /></Modal>;
7095    }
7096    const groups: { label: string; icon: keyof typeof Ionicons.glyphMap; items: string[] }[] = [
7097      { label: 'Security', icon: 'shield-half-outline', items: securityTips(browser) },
7098      { label: 'Privacy', icon: 'glasses-outline', items: privacyTips(browser) },
7099    ];
7100    const total = groups.reduce((n, g) => n + g.items.length, 0);
7101    const done = Object.values(checked).filter(Boolean).length;
7102    return (
7103      <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
7104        <View style={modalStyles.backdrop} pointerEvents="box-none">
7105          <Pressable style={modalStyles.backdropFill} onPress={onClose} accessibilityLabel="Close checklist" />
7106          <View style={modalStyles.sheetTall}>
7107            <View style={modalStyles.headerSticky}>
7108              <View style={[modalStyles.headerIcon, modalStyles.glossaryHeaderIcon]}>
7109                <Ionicons name="checkbox-outline" size={22} color={Palette.highlight} />
7110              </View>
7111              <View style={modalStyles.headerText}>
7112                <Text style={modalStyles.encTitle}>Checklist</Text>
7113                <Text style={modalStyles.encSubtitle}>
7114                  {browser.name} · {done}/{total} done
7115                </Text>
7116              </View>
7117            </View>
7118            <ScrollView
7119              style={[modalStyles.explainScrollInWrap, { maxHeight: scrollMaxH }]}
7120              contentContainerStyle={modalStyles.explainScrollContent}
7121              showsVerticalScrollIndicator
7122              persistentScrollbar
7123              indicatorStyle="white"
7124              nestedScrollEnabled>
7125              {groups.map((g) => (
7126                <View key={g.label} style={modalStyles.section}>
7127                  <View style={modalStyles.glossaryGroupHeader}>
7128                    <Ionicons name={g.icon} size={14} color={Palette.accentBright} />
7129                    <Text style={modalStyles.sectionTitle}>{g.label}</Text>
7130                  </View>
7131                  {g.items.map((item, i) => {
7132                    const key = `${g.label}:${i}`;
7133                    const on = !!checked[key];
7134                    return (
7135                      <Pressable
7136                        key={key}
7137                        onPress={() => setChecked((c) => ({ ...c, [key]: !c[key] }))}
7138                        style={({ pressed }) => [
7139                          modalStyles.checklistRow,
7140                          pressed && modalStyles.actionBtnPressed,
7141                        ]}>
7142                        <Ionicons
7143                          name={on ? 'checkbox' : 'square-outline'}
7144                          size={18}
7145                          color={on ? Palette.highlight : Palette.textMuted}
7146                          style={{ marginTop: 1 }}
7147                        />
7148                        <Text
7149                          style={[
7150                            modalStyles.checklistText,
7151                            on && modalStyles.checklistTextDone,
7152                          ]}>
7153                          {item}
7154                        </Text>
7155                      </Pressable>
7156                    );
7157                  })}
7158                </View>
7159              ))}
7160              <Text style={modalStyles.encOptimNote}>
7161                Tick items off as you apply them in {browser.name}. (Progress isn’t saved yet.)
7162              </Text>
7163            </ScrollView>
7164            <View style={modalStyles.explainCloseRow}>
7165              <View style={modalStyles.cycleBtnSpacer} />
7166              <Pressable
7167                onPress={onClose}
7168                style={({ pressed }) => [editorStyles.cancelBtn, pressed && modalStyles.actionBtnPressed]}>
7169                <Text style={modalStyles.closeBtnText}>Close</Text>
7170              </Pressable>
7171              <View style={modalStyles.cycleBtnSpacer} />
7172            </View>
7173          </View>
7174        </View>
7175      </Modal>
7176    );
7177  }
7178  
7179  function PrivacyExplanationModal({
7180    browser,
7181    onClose,
7182    onSelect,
7183    canSelect = true,
7184    list,
7185    onNavigate,
7186  }: {
7187    browser: Browser | null;
7188    onClose: () => void;
7189    /** When set, renders a "Select" button in the modal header — used when
7190     *  the modal is opened from the browser-picker context. */
7191    onSelect?: () => void;
7192    /** When false, the header Select pill is hidden even if onSelect is set.
7193     *  Picker passes false for not-installed browsers. */
7194    canSelect?: boolean;
7195    /** Optional list of browsers to cycle through with ‹ / › chevrons. The
7196     *  modal computes prev/next from the current browser's index in this
7197     *  list and calls onNavigate with the new browser. */
7198    list?: Browser[];
7199    onNavigate?: (b: Browser) => void;
7200  }) {
7201    const explanation = browser ? explainPrivacy(browser) : null;
7202    // Bound the scroll region with a deterministic pixel maxHeight so
7203    // the nested-flex math doesn't matter — sheetTall's content-sized
7204    // parent chain doesn't propagate a definite height for `flex: 1`
7205    // to claim on Android, and flexShrink alone won't shrink a
7206    // ScrollView below its content. ~50% leaves room for the header,
7207    // summary, info pills, intent rows, and Close button.
7208    const { height: viewportH } = useWindowDimensions();
7209    // The ScrollView now wraps the WHOLE modal body (tagline + info
7210    // pills + ScoreBreakdown + protections + privacytests + intents)
7211    // so anywhere the user swipes inside the modal, scrolling engages
7212    // — the previous "split" layout had three non-scrolling rows
7213    // above the ScrollView that swallowed gestures silently.
7214    // ~50% viewport leaves room for the sticky header + close row,
7215    // sized below the typical content height so scrolling actually
7216    // overflows.
7217    const scrollMaxH = Math.max(280, Math.floor(viewportH * 0.5));
7218    // Single tab axis — Security | Privacy. Each tab shows its score
7219    // breakdown + details, with the optimization (hardening) tips
7220    // appended at the bottom. Reset to Security whenever the modal
7221    // opens against a new browser.
7222    const [mainTab, setMainTab] = useState<'security' | 'privacy'>('security');
7223    // Standalone glossary + configuration-checklist modal open states.
7224    const [glossaryOpen, setGlossaryOpen] = useState(false);
7225    const [checklistOpen, setChecklistOpen] = useState(false);
7226    useEffect(() => {
7227      if (browser) setMainTab('security');
7228    }, [browser?.id]);
7229    // Two distilled intent rows the user actually cares about:
7230    //   - Mini-tab (Custom Tab support)
7231    //   - Incognito / Isolation (strongest private session we can request)
7232    // We pull each row's spec from the per-mode breakdown explainPrivacy
7233    // already computes, so the docs and the launch code can't drift.
7234    // Cycle through `list` with the chevrons. Wraps at the ends — feels
7235    // closer to a carousel than a dead-end. Hidden when list has 0 or 1
7236    // entries (nothing to cycle to).
7237    const canCycle = !!(list && onNavigate && browser && list.length > 1);
7238    const cycleIdx = canCycle && browser ? list!.findIndex((b) => b.id === browser.id) : -1;
7239    const goPrev = canCycle && cycleIdx >= 0
7240      ? () => onNavigate!(list![(cycleIdx - 1 + list!.length) % list!.length])
7241      : null;
7242    const goNext = canCycle && cycleIdx >= 0
7243      ? () => onNavigate!(list![(cycleIdx + 1) % list!.length])
7244      : null;
7245    const miniMode = explanation?.modes.find((m) => m.mode === 'mini');
7246    const privateModeRow =
7247      explanation?.modes.find((m) => m.mode === 'max' && m.supported) ??
7248      explanation?.modes.find((m) => m.mode === 'private' && m.supported) ??
7249      explanation?.modes.find((m) => m.mode === 'private');
7250    return (
7251      <Modal
7252        visible={browser !== null}
7253        transparent
7254        animationType="fade"
7255        onRequestClose={onClose}>
7256        {/* Sibling-pattern modal chrome: the backdrop Pressable and
7257            the sheet View are SIBLINGS, not parent-child. This stops
7258            un-claimed scroll-start touches inside the sheet from
7259            bubbling up to the backdrop's onPress and closing the
7260            modal. With the previous parent-child layout, the
7261            backdrop Pressable saw every touch that ScrollView didn't
7262            claim on touch-start (RN ScrollView only claims on MOVE,
7263            not start) and fired onClose on release. */}
7264        <View style={modalStyles.backdrop} pointerEvents="box-none">
7265          <Pressable
7266            style={modalStyles.backdropFill}
7267            onPress={onClose}
7268            accessibilityLabel="Close modal"
7269          />
7270          <View style={modalStyles.sheetTall}>
7271            {browser && explanation ? (
7272              <>
7273                {/* Sticky header — outside the ScrollView so it stays
7274                    visible while the user scrolls the writeup. */}
7275                <View style={modalStyles.headerSticky}>
7276                  {ASSET_ICONS[browser.id] ? (
7277                    <Image source={ASSET_ICONS[browser.id]} style={modalStyles.headerIcon} />
7278                  ) : (
7279                    <View
7280                      style={[modalStyles.headerIcon, { backgroundColor: browser.tint }]}
7281                    />
7282                  )}
7283                  <View style={modalStyles.headerText}>
7284                    <Text style={modalStyles.encTitle}>{browser.name}</Text>
7285                    <Text style={modalStyles.encSubtitle}>Encyclopedia</Text>
7286                  </View>
7287                  {onSelect && canSelect ? (
7288                    <Pressable
7289                      onPress={onSelect}
7290                      style={({ pressed }) => [
7291                        modalStyles.headerSelectPill,
7292                        pressed && modalStyles.actionBtnPressed,
7293                      ]}>
7294                      <Ionicons name="checkmark" size={13} color={Palette.text} />
7295                      <Text style={modalStyles.headerSelectText}>Select</Text>
7296                    </Pressable>
7297                  ) : null}
7298                  {/* Top-right close — replaces the bottom Close button. */}
7299                  <Pressable
7300                    onPress={onClose}
7301                    hitSlop={10}
7302                    accessibilityLabel="Close"
7303                    style={({ pressed }) => [
7304                      modalStyles.headerCloseBtn,
7305                      pressed && modalStyles.actionBtnPressed,
7306                    ]}>
7307                    <Ionicons name="close" size={20} color={Palette.textMuted} />
7308                  </Pressable>
7309                </View>
7310                {/* Browser summary + capability pills — general
7311                    context, shown ONCE here in the sticky region above
7312                    the tabs (not per-tab) so it isn't repeated when
7313                    switching Security ↔ Privacy. */}
7314                <Text style={modalStyles.summary}>{explanation.summary}</Text>
7315                <View style={modalStyles.infoPillsRow}>
7316                  {BROWSER_AVAILABILITY[browser.id] ? (
7317                    <View style={modalStyles.availabilityPill}>
7318                      <Ionicons name="information-circle" size={13} color={Palette.accentBright} />
7319                      <Text style={modalStyles.availabilityPillText}>
7320                        {BROWSER_AVAILABILITY[browser.id].long}
7321                      </Text>
7322                    </View>
7323                  ) : null}
7324                  {BROWSER_HAS_EXTENSIONS[browser.id] ? (
7325                    <View style={modalStyles.capabilityPill}>
7326                      <Ionicons name="extension-puzzle-outline" size={13} color={Palette.accentBright} />
7327                      <Text style={modalStyles.capabilityPillText}>Supports extensions</Text>
7328                    </View>
7329                  ) : null}
7330                </View>
7331  
7332                {/* Action launchers above the tabs — Glossary (left)
7333                    and Configuration checklist (right beside it). */}
7334                <View style={modalStyles.encGlossaryRow}>
7335                  <Pressable
7336                    onPress={() => setGlossaryOpen(true)}
7337                    hitSlop={8}
7338                    style={({ pressed }) => [
7339                      modalStyles.encGlossaryBtn,
7340                      pressed && modalStyles.actionBtnPressed,
7341                    ]}>
7342                    <Ionicons name="book-outline" size={13} color={Palette.accentBright} />
7343                    <Text style={modalStyles.encGlossaryBtnText}>Glossary</Text>
7344                  </Pressable>
7345                  <Pressable
7346                    onPress={() => setChecklistOpen(true)}
7347                    hitSlop={8}
7348                    style={({ pressed }) => [
7349                      modalStyles.encGlossaryBtn,
7350                      pressed && modalStyles.actionBtnPressed,
7351                    ]}>
7352                    <Ionicons name="checkbox-outline" size={13} color={Palette.accentBright} />
7353                    <Text style={modalStyles.encGlossaryBtnText}>Checklist</Text>
7354                  </Pressable>
7355                </View>
7356  
7357                {/* Tab strip — single axis (Security | Privacy). Each
7358                    tab shows its score breakdown + details, with the
7359                    hardening tips appended at the bottom. Sticky above
7360                    the scrolling body. */}
7361                <View style={modalStyles.encTabStrip}>
7362                  {(['security', 'privacy'] as const).map((t) => (
7363                    <Pressable
7364                      key={t}
7365                      onPress={() => setMainTab(t)}
7366                      style={({ pressed }) => [
7367                        modalStyles.encTab,
7368                        mainTab === t && modalStyles.encTabActive,
7369                        pressed && modalStyles.actionBtnPressed,
7370                      ]}>
7371                      <Ionicons
7372                        name={t === 'security' ? 'shield-half-outline' : 'glasses-outline'}
7373                        size={14}
7374                        color={mainTab === t ? Palette.text : Palette.textMuted}
7375                      />
7376                      <Text
7377                        style={[
7378                          modalStyles.encTabText,
7379                          mainTab === t && modalStyles.encTabTextActive,
7380                        ]}>
7381                        {t === 'security' ? 'Security' : 'Privacy'}
7382                      </Text>
7383                    </Pressable>
7384                  ))}
7385                </View>
7386  
7387                <ScrollView
7388                  style={[modalStyles.explainScrollInWrap, { maxHeight: scrollMaxH }]}
7389                  contentContainerStyle={modalStyles.explainScrollContent}
7390                  showsVerticalScrollIndicator
7391                  persistentScrollbar
7392                  indicatorStyle="white"
7393                  nestedScrollEnabled>
7394                  <ScoreBreakdown browser={browser} only={mainTab} />
7395  
7396                  {/* Privacy tab carries the privacytests breakdown,
7397                      built-in protections, and supported-intent rows.
7398                      Security tab stops at the score breakdown. */}
7399                  {mainTab === 'privacy' ? (
7400                    <>
7401                      {explanation.testScore ? (
7402                        <View style={modalStyles.section}>
7403                          <Text style={modalStyles.sectionTitle}>Privacytests.org score</Text>
7404                          <Text style={modalStyles.scoreLine}>
7405                            <Text style={modalStyles.scoreBig}>{explanation.testScore.totalPassed}</Text>
7406                            <Text style={modalStyles.bulletText}>
7407                              {' '}/ {explanation.testScore.totalTests} tests passed
7408                            </Text>
7409                          </Text>
7410                          <Text style={modalStyles.scoreVersion}>
7411                            v{explanation.testScore.version} · snapshot {PRIVACY_TEST_DATE}
7412                          </Text>
7413                          <View style={modalStyles.testCategoryGrid}>
7414                            {(Object.entries(explanation.testScore.bySection) as [string, { passed: number; total: number }][])
7415                              .filter(([, v]) => v.total > 0)
7416                              .map(([key, v]) => {
7417                                const pct = Math.round((v.passed / v.total) * 100);
7418                                const colour =
7419                                  pct >= 80 ? Palette.highlight :
7420                                  pct >= 55 ? Palette.accentBright :
7421                                  pct >= 35 ? '#d4a04a' :
7422                                              '#c87070';
7423                                return (
7424                                  <View key={key} style={modalStyles.testCategoryRow}>
7425                                    <Text style={modalStyles.testCategoryLabel}>
7426                                      {TEST_CATEGORY_LABEL[key] ?? key}
7427                                    </Text>
7428                                    <View style={modalStyles.testCategoryTrack}>
7429                                      <View style={[modalStyles.testCategoryFill, { width: `${pct}%`, backgroundColor: colour }]} />
7430                                    </View>
7431                                    <Text style={modalStyles.testCategoryRatio}>{v.passed}/{v.total}</Text>
7432                                  </View>
7433                                );
7434                              })}
7435                          </View>
7436                        </View>
7437                      ) : null}
7438  
7439                      {explanation.protections.length > 0 ? (
7440                        <View style={modalStyles.section}>
7441                          <Text style={modalStyles.sectionTitle}>Browser protections</Text>
7442                          {explanation.protections.map((line, i) => (
7443                            <View key={i} style={modalStyles.bulletRow}>
7444                              <Text style={modalStyles.bullet}>·</Text>
7445                              <Text style={modalStyles.bulletText}>{line}</Text>
7446                            </View>
7447                          ))}
7448                        </View>
7449                      ) : null}
7450  
7451                      <Text style={modalStyles.sectionTitleSolo}>Supported intents</Text>
7452                      {miniMode ? (
7453                        <View style={modalStyles.intentRow}>
7454                          <View style={modalStyles.intentHeader}>
7455                            <Ionicons
7456                              name={miniMode.supported ? 'checkmark-circle' : 'close-circle-outline'}
7457                              size={16}
7458                              color={miniMode.supported ? Palette.accentBright : Palette.textMuted}
7459                            />
7460                            <Text style={modalStyles.intentLabel}>Mini-tab</Text>
7461                            <Text style={modalStyles.intentBadge}>
7462                              {miniMode.supported ? 'Supported' : 'Not supported'}
7463                            </Text>
7464                          </View>
7465                          <Text style={modalStyles.intentDesc}>{miniMode.description}</Text>
7466                        </View>
7467                      ) : null}
7468                      {privateModeRow ? (
7469                        <View style={modalStyles.intentRow}>
7470                          <View style={modalStyles.intentHeader}>
7471                            <Ionicons
7472                              name={privateModeRow.supported ? 'checkmark-circle' : 'close-circle-outline'}
7473                              size={16}
7474                              color={privateModeRow.supported ? Palette.accentBright : Palette.textMuted}
7475                            />
7476                            <Text style={modalStyles.intentLabel}>Incognito / Isolation</Text>
7477                            <Text style={modalStyles.intentBadge}>
7478                              {privateModeRow.supported ? 'Supported' : 'Not supported'}
7479                            </Text>
7480                          </View>
7481                          <Text style={modalStyles.intentDesc}>{privateModeRow.description}</Text>
7482                        </View>
7483                      ) : null}
7484                    </>
7485                  ) : null}
7486  
7487                  {/* Optimization (hardening) tips — appended at the
7488                      bottom of each tab's content. */}
7489                  <View style={modalStyles.section}>
7490                    <Text style={modalStyles.sectionTitle}>
7491                      {mainTab === 'security' ? 'Harden security' : 'Harden privacy'}
7492                    </Text>
7493                    {(mainTab === 'security' ? securityTips(browser) : privacyTips(browser)).map(
7494                      (tip, i) => (
7495                        <View key={i} style={modalStyles.bulletRow}>
7496                          <Ionicons
7497                            name="caret-forward"
7498                            size={11}
7499                            color={Palette.highlight}
7500                            style={{ marginTop: 2 }}
7501                          />
7502                          <Text style={modalStyles.bulletText}>{tip}</Text>
7503                        </View>
7504                      ),
7505                    )}
7506                    <Text style={modalStyles.encOptimNote}>
7507                      Settings live inside {browser.name} itself — Warden documents them, it doesn’t apply them.
7508                    </Text>
7509                  </View>
7510                </ScrollView>
7511                {/* Bottom row now holds ONLY the browser-cycle
7512                    chevrons (close moved to the header X). Hidden
7513                    entirely when there's nothing to cycle to. */}
7514                {canCycle ? (
7515                  <View style={modalStyles.explainCloseRow}>
7516                    <Pressable
7517                      onPress={goPrev ?? undefined}
7518                      hitSlop={10}
7519                      accessibilityLabel="Previous browser"
7520                      style={({ pressed }) => [
7521                        modalStyles.cycleBtn,
7522                        pressed && modalStyles.actionBtnPressed,
7523                      ]}>
7524                      <Ionicons name="chevron-back" size={18} color={Palette.text} />
7525                    </Pressable>
7526                    <Pressable
7527                      onPress={goNext ?? undefined}
7528                      hitSlop={10}
7529                      accessibilityLabel="Next browser"
7530                      style={({ pressed }) => [
7531                        modalStyles.cycleBtn,
7532                        pressed && modalStyles.actionBtnPressed,
7533                      ]}>
7534                      <Ionicons name="chevron-forward" size={18} color={Palette.text} />
7535                    </Pressable>
7536                  </View>
7537                ) : null}
7538              </>
7539            ) : null}
7540          </View>
7541        </View>
7542        {/* Standalone glossary modal — all terms grouped by axis. */}
7543        <GlossaryModal visible={glossaryOpen} onClose={() => setGlossaryOpen(false)} />
7544        {/* Configuration checklist — recommended hardening settings as
7545            a to-do. Local check state only (no persistence yet). */}
7546        <ConfigChecklistModal
7547          browser={browser}
7548          visible={checklistOpen}
7549          onClose={() => setChecklistOpen(false)}
7550        />
7551      </Modal>
7552    );
7553  }
7554  
7555  const modalStyles = StyleSheet.create({
7556    backdrop: {
7557      flex: 1,
7558      backgroundColor: 'rgba(0,0,0,0.65)',
7559      justifyContent: 'center',
7560      alignItems: 'center',
7561      padding: 20,
7562    },
7563    // Used by the sibling-pattern modals (encyclopedia, etc.) — an
7564    // absolutely-filled Pressable that catches taps OUTSIDE the
7565    // centered sheet. Without this, the backdrop visual is on the
7566    // parent View (no Pressable), so taps in the dimmed margin
7567    // wouldn't close the modal.
7568    backdropFill: {
7569      ...StyleSheet.absoluteFillObject,
7570    },
7571    sheet: {
7572      width: '100%',
7573      maxWidth: 380,
7574      backgroundColor: Palette.surface,
7575      borderWidth: 1,
7576      borderColor: Palette.border,
7577      borderRadius: 16,
7578      padding: 18,
7579    },
7580    // KeyboardAvoidingView wrapper for modal sheets that hold TextInputs.
7581    // width:100% + maxWidth match the sheet so layout doesn't shift when
7582    // the keyboard opens; alignItems centers the sheet horizontally
7583    // inside the backdrop. maxHeight:'100%' gives sheetTall's
7584    // percent-based maxHeight a reference to compute against — without
7585    // it the sheet grew to full content height and clipped past the
7586    // viewport.
7587    // Wrap claims the full backdrop area (height + width 100%) — a
7588    // defined dimension on the wrap is what lets the nested sheet's
7589    // percent-based maxHeight (88%) and the flex:1 ScrollView inside
7590    // it both resolve cleanly. justifyContent / alignItems center the
7591    // sheet inside this full-area wrap. maxWidth keeps the wrap from
7592    // spanning past the sheet's natural width.
7593    keyboardWrap: {
7594      width: '100%',
7595      height: '100%',
7596      maxWidth: 380,
7597      alignItems: 'center',
7598      justifyContent: 'center',
7599    },
7600    // Privacy Features modal — bounded so the ScrollView inside can actually
7601    // overflow + scroll instead of growing the sheet past the viewport.
7602    // Softer radius + a touch more padding so the cards inside don't
7603    // crash up against the outer edge.
7604    sheetTall: {
7605      width: '100%',
7606      maxWidth: 380,
7607      maxHeight: '88%',
7608      backgroundColor: Palette.surface,
7609      borderWidth: 1,
7610      borderColor: Palette.border,
7611      borderRadius: 20,
7612      padding: 16,
7613      flexShrink: 1,
7614    },
7615    header: {
7616      flexDirection: 'row',
7617      alignItems: 'center',
7618      gap: 12,
7619      marginBottom: 14,
7620    },
7621    // Encyclopedia-only header variant: a hairline + a touch more
7622    // bottom space so the sticky header reads as a distinct band
7623    // above the scrolling writeup.
7624    headerSticky: {
7625      flexDirection: 'row',
7626      alignItems: 'center',
7627      gap: 12,
7628      paddingBottom: 12,
7629      marginBottom: 4,
7630      borderBottomWidth: 1,
7631      borderBottomColor: Palette.border,
7632    },
7633    headerIcon: {
7634      width: 44, height: 44, borderRadius: 10,
7635      backgroundColor: Palette.bgElevated,
7636    },
7637    headerText: { flex: 1 },
7638    // Encyclopedia header — browser name in the brand serif, left
7639    // aligned beside the icon. Distinct from the generic centered
7640    // modalStyles.title so reordering / sizing here doesn't ripple
7641    // into the action sheets that share `title`.
7642    encTitle: {
7643      fontFamily: Fonts?.serif,
7644      fontSize: 23,
7645      color: Palette.text,
7646      fontWeight: '400',
7647      letterSpacing: 0.3,
7648    },
7649    encSubtitle: {
7650      fontSize: 11,
7651      color: Palette.accentBright,
7652      fontStyle: 'italic',
7653      letterSpacing: 0.6,
7654      marginTop: 1,
7655    },
7656    // Main tab strip (Security | Privacy) — segmented pills.
7657    encTabStrip: {
7658      flexDirection: 'row',
7659      gap: 6,
7660      marginBottom: 6,
7661    },
7662    encTab: {
7663      flex: 1,
7664      flexDirection: 'row',
7665      alignItems: 'center',
7666      justifyContent: 'center',
7667      gap: 6,
7668      paddingVertical: 8,
7669      borderRadius: 10,
7670      borderWidth: 1,
7671      borderColor: Palette.border,
7672      backgroundColor: Palette.bgElevated,
7673    },
7674    encTabActive: {
7675      borderColor: Palette.highlight,
7676      backgroundColor: Palette.surface,
7677    },
7678    encTabText: {
7679      fontSize: 12,
7680      color: Palette.textMuted,
7681      fontWeight: '700',
7682      letterSpacing: 0.4,
7683    },
7684    encTabTextActive: { color: Palette.text },
7685    encOptimNote: {
7686      fontSize: 10,
7687      color: Palette.textMuted,
7688      fontStyle: 'italic',
7689      lineHeight: 15,
7690      marginTop: 10,
7691    },
7692    // Rounded inset panel for the Presets list — matches the
7693    // encyclopedia's reading-surface treatment.
7694    presetsScrollPanel: {
7695      borderWidth: 1,
7696      borderColor: Palette.border,
7697      borderRadius: 14,
7698      backgroundColor: Palette.bg,
7699      overflow: 'hidden',
7700    },
7701    // Left-aligned launcher row above the encyclopedia tabs — Glossary
7702    // + Configuration checklist sit side by side.
7703    encGlossaryRow: {
7704      flexDirection: 'row',
7705      justifyContent: 'flex-start',
7706      gap: 8,
7707      marginBottom: 6,
7708    },
7709    encGlossaryBtn: {
7710      flexDirection: 'row',
7711      alignItems: 'center',
7712      gap: 5,
7713      paddingVertical: 5,
7714      paddingHorizontal: 10,
7715      borderRadius: 999,
7716      borderWidth: 1,
7717      borderColor: Palette.border,
7718      backgroundColor: Palette.bgElevated,
7719    },
7720    encGlossaryBtnText: {
7721      fontSize: 11,
7722      color: Palette.accentBright,
7723      fontWeight: '700',
7724      letterSpacing: 0.3,
7725    },
7726    // Standalone GlossaryModal interior.
7727    glossaryHeaderIcon: {
7728      alignItems: 'center',
7729      justifyContent: 'center',
7730    },
7731    glossaryGroupHeader: {
7732      flexDirection: 'row',
7733      alignItems: 'center',
7734      gap: 6,
7735      marginBottom: 8,
7736    },
7737    glossaryEntry: { marginBottom: 12 },
7738    glossaryTerm: {
7739      fontSize: 13,
7740      color: Palette.text,
7741      fontWeight: '700',
7742      letterSpacing: 0.2,
7743      marginBottom: 2,
7744    },
7745    glossaryDef: {
7746      fontSize: 12,
7747      color: Palette.textMuted,
7748      lineHeight: 17,
7749    },
7750    // Configuration checklist rows.
7751    checklistRow: {
7752      flexDirection: 'row',
7753      alignItems: 'flex-start',
7754      gap: 9,
7755      paddingVertical: 5,
7756    },
7757    checklistText: {
7758      flex: 1,
7759      fontSize: 12,
7760      color: Palette.text,
7761      lineHeight: 17,
7762    },
7763    checklistTextDone: {
7764      color: Palette.textMuted,
7765      textDecorationLine: 'line-through',
7766    },
7767    headerSelectPill: {
7768      flexDirection: 'row',
7769      alignItems: 'center',
7770      gap: 4,
7771      paddingVertical: 6,
7772      paddingHorizontal: 10,
7773      borderRadius: 999,
7774      backgroundColor: Palette.highlight,
7775    },
7776    headerSelectText: {
7777      fontSize: 12,
7778      fontWeight: '700',
7779      color: Palette.text,
7780    },
7781    // Top-right close button in the encyclopedia header — bordered
7782    // circular tile so it reads as a tappable control, not a bare
7783    // glyph. Replaces the old bottom Close button.
7784    headerCloseBtn: {
7785      width: 34,
7786      height: 34,
7787      borderRadius: 17,
7788      alignItems: 'center',
7789      justifyContent: 'center',
7790      borderWidth: 1,
7791      borderColor: Palette.border,
7792      backgroundColor: Palette.bgElevated,
7793      marginLeft: 4,
7794    },
7795    explainCloseRow: {
7796      flexDirection: 'row',
7797      alignItems: 'center',
7798      justifyContent: 'space-between',
7799      marginTop: 12,
7800      gap: 10,
7801    },
7802    // Left-aligned footer used by deep config modals — the BackPill
7803    // anchors to the start of the row so the chevron lives where the
7804    // user's eye expects "back" to be.
7805    backCloseRow: {
7806      flexDirection: 'row',
7807      alignItems: 'center',
7808      justifyContent: 'flex-start',
7809      marginTop: 12,
7810    },
7811    cycleBtn: {
7812      width: 36,
7813      height: 36,
7814      borderRadius: 18,
7815      alignItems: 'center',
7816      justifyContent: 'center',
7817      borderWidth: 1,
7818      borderColor: Palette.border,
7819      backgroundColor: Palette.bgElevated,
7820    },
7821    cycleBtnSpacer: { width: 36, height: 36 },
7822    headerChip: {
7823      flexDirection: 'row',
7824      alignItems: 'center',
7825      gap: 5,
7826      paddingVertical: 6,
7827      paddingHorizontal: 10,
7828      borderRadius: 8,
7829      borderWidth: 1,
7830      borderColor: Palette.border,
7831      backgroundColor: Palette.bgElevated,
7832    },
7833    headerChipText: { fontSize: 11, color: Palette.accentBright, fontWeight: '600', letterSpacing: 0.3 },
7834    visibleToggle: {
7835      flexDirection: 'row',
7836      alignItems: 'center',
7837      gap: 7,
7838      paddingVertical: 6,
7839      paddingHorizontal: 10,
7840      borderRadius: 8,
7841      borderWidth: 1,
7842      borderColor: Palette.border,
7843      backgroundColor: Palette.bgElevated,
7844    },
7845    visibleCheckbox: {
7846      width: 16, height: 16, borderRadius: 4,
7847      borderWidth: 1.5, borderColor: Palette.textMuted,
7848      alignItems: 'center', justifyContent: 'center',
7849    },
7850    visibleCheckboxOn: {
7851      backgroundColor: Palette.accentBright,
7852      borderColor: Palette.accentBright,
7853    },
7854    visibleLabel: { fontSize: 12, color: Palette.text, fontWeight: '600' },
7855    moveRow: { flexDirection: 'row', gap: 8 },
7856    moveBtn: { flex: 1, justifyContent: 'center' },
7857    moveBtnDisabled: { opacity: 0.4 },
7858    deleteAction: {
7859      borderColor: '#8a3a3a',
7860      backgroundColor: '#2a1414',
7861    },
7862    deleteActionText: { fontSize: 14, color: '#f0a0a0', fontWeight: '500' },
7863    title: {
7864      fontSize: 17,
7865      color: Palette.text,
7866      fontWeight: '600',
7867      textAlign: 'center',
7868    },
7869    subtitle: {
7870      fontSize: 11,
7871      color: Palette.textMuted,
7872      fontFamily: Fonts?.mono,
7873      marginTop: 2,
7874    },
7875  
7876    actionList: { gap: 8 },
7877    actionBtn: {
7878      flexDirection: 'row',
7879      alignItems: 'center',
7880      gap: 10,
7881      paddingVertical: 12,
7882      paddingHorizontal: 14,
7883      borderRadius: 10,
7884      borderWidth: 1,
7885      borderColor: Palette.border,
7886      backgroundColor: Palette.bgElevated,
7887    },
7888    actionBtnPrimary: {
7889      flexDirection: 'row',
7890      alignItems: 'center',
7891      gap: 10,
7892      paddingVertical: 12,
7893      paddingHorizontal: 14,
7894      borderRadius: 10,
7895      borderWidth: 1,
7896      borderColor: Palette.accentBright,
7897      backgroundColor: Palette.bgElevated,
7898    },
7899    // Destructive variant of actionBtn — amber border + text colour so
7900    // Delete / Remove rows read as "you sure?" before tap.
7901    actionBtnDestructive: { borderColor: '#c87070' },
7902    actionBtnPressed: { opacity: 0.75, transform: [{ scale: 0.99 }] },
7903    actionBtnText: { fontSize: 14, color: Palette.text, fontWeight: '500' },
7904    actionBtnPrimaryText: { fontSize: 14, color: Palette.highlight, fontWeight: '600' },
7905    closeBtn: {
7906      marginTop: 14,
7907      paddingVertical: 12,
7908      alignItems: 'center',
7909      borderRadius: 10,
7910      borderWidth: 1,
7911      borderColor: Palette.border,
7912      backgroundColor: Palette.bg,
7913    },
7914    closeBtnText: { fontSize: 14, color: Palette.textMuted, fontWeight: '500' },
7915  
7916    summary: {
7917      fontSize: 12,
7918      color: Palette.textMuted,
7919      lineHeight: 17,
7920      marginBottom: 6,
7921    },
7922    // Row that hosts availability + capability pills under the
7923    // encyclopedia summary. flexWrap so multiple pills line-break onto
7924    // additional rows when space runs out.
7925    infoPillsRow: {
7926      flexDirection: 'row',
7927      flexWrap: 'wrap',
7928      alignItems: 'center',
7929      gap: 6,
7930      marginBottom: 6,
7931    },
7932    // Availability caveat shown under the encyclopedia summary. A
7933    // bordered pill with an info glyph so it reads as a constraint
7934    // notice (not a normal piece of text) before the user dives in.
7935    availabilityPill: {
7936      flexDirection: 'row',
7937      alignItems: 'center',
7938      gap: 6,
7939      paddingVertical: 5,
7940      paddingHorizontal: 9,
7941      borderRadius: 999,
7942      borderWidth: 1,
7943      borderColor: Palette.accentBright,
7944      backgroundColor: Palette.bgElevated,
7945    },
7946    availabilityPillText: {
7947      fontSize: 11,
7948      color: Palette.accentBright,
7949      fontWeight: '700',
7950      letterSpacing: 0.2,
7951    },
7952    // Capability pill — softer chrome than the availability pill (which
7953    // is a constraint notice). Marks positive attributes like "Supports
7954    // extensions" so they read as feature badges, not warnings.
7955    capabilityPill: {
7956      flexDirection: 'row',
7957      alignItems: 'center',
7958      gap: 6,
7959      paddingVertical: 5,
7960      paddingHorizontal: 9,
7961      borderRadius: 999,
7962      borderWidth: 1,
7963      borderColor: Palette.border,
7964      backgroundColor: Palette.bgElevated,
7965    },
7966    capabilityPillText: {
7967      fontSize: 11,
7968      color: Palette.text,
7969      fontWeight: '600',
7970      letterSpacing: 0.2,
7971    },
7972    // Wrapper that lays the custom scrollbar track + ScrollView side by
7973    // side inside the encyclopedia modal.
7974    explainScrollWrap: {
7975      flexDirection: 'row',
7976      marginTop: 4,
7977      marginBottom: 4,
7978      // Height is bounded inline via `useWindowDimensions` in the
7979      // PrivacyExplanationModal component — flex math doesn't survive
7980      // the sheetTall's content-sized parent chain on Android, so we
7981      // anchor the scroll region to a deterministic pixel cap there.
7982    },
7983    explainScrollTrack: {
7984      width: 5,
7985      marginRight: 8,
7986      marginTop: 4,
7987      marginBottom: 4,
7988      borderRadius: 3,
7989      backgroundColor: Palette.bgElevated,
7990      overflow: 'hidden',
7991    },
7992    explainScrollThumb: {
7993      width: 5,
7994      borderRadius: 3,
7995      backgroundColor: Palette.accentDeep,
7996    },
7997    // Base ScrollView style used by the FlowEditorModal. Blends with the
7998    // FlowEditorModal form-body scroll. flexShrink (not flex) so the
7999    // ScrollView sizes to its content, and the parent sheetTall's
8000    // own flexShrink kicks in when content exceeds maxHeight: '88%'
8001    // — clipping + scrolling. flex:1 here breaks the sheet's height
8002    // negotiation through the KeyboardAvoidingView wrapper, collapsing
8003    // the form to 0 height.
8004    explainScroll: {
8005      flexShrink: 1,
8006    },
8007    // Encyclopedia-specific override — lives inside `explainScrollWrap`
8008    // (a flex row), so it needs `flex: 1` to claim the remaining width
8009    // after the scrollbar track on the left. Margins are managed by the
8010    // wrapper, so they're zeroed here.
8011    explainScrollInWrap: {
8012      // No flex — sized to its content, capped by the inline maxHeight
8013      // applied at the call site (useWindowDimensions × percentage).
8014      // The earlier `flex: 1` made sense when this ScrollView sat
8015      // inside a flex-row wrap; as a direct child of the column-flex
8016      // sheetTall it collapsed to 0 height.
8017      //
8018      // Rounded, fully-bordered panel — reads as a distinct inset
8019      // "reading surface" within the sheet rather than a pair of bare
8020      // hairlines. overflow:hidden clips the scrolling content to the
8021      // rounded corners.
8022      marginTop: 2,
8023      marginBottom: 4,
8024      borderWidth: 1,
8025      borderColor: Palette.border,
8026      borderRadius: 14,
8027      backgroundColor: Palette.bg,
8028      overflow: 'hidden',
8029    },
8030    explainScrollContent: {
8031      paddingVertical: 14,
8032      paddingHorizontal: 12,
8033    },
8034  
8035    // Two-intent summary rows. Each intent renders as a soft card so
8036    // the supported/unsupported state is glanceable without relying
8037    // on hairline dividers.
8038    intentRow: {
8039      paddingVertical: 10,
8040      paddingHorizontal: 11,
8041      marginTop: 8,
8042      borderRadius: 10,
8043      borderWidth: 1,
8044      borderColor: Palette.border,
8045      backgroundColor: Palette.bgElevated,
8046    },
8047    intentHeader: { flexDirection: 'row', alignItems: 'center', gap: 8 },
8048    intentLabel: {
8049      flex: 1,
8050      fontSize: 13,
8051      color: Palette.text,
8052      fontWeight: '700',
8053      letterSpacing: 0.3,
8054    },
8055    intentBadge: {
8056      fontSize: 10,
8057      color: Palette.accentBright,
8058      fontWeight: '700',
8059      letterSpacing: 0.5,
8060      textTransform: 'uppercase',
8061    },
8062    intentDesc: {
8063      fontSize: 11,
8064      color: Palette.textMuted,
8065      lineHeight: 16,
8066      marginTop: 4,
8067      paddingLeft: 24,
8068    },
8069  
8070    // Sections inside the encyclopedia. Each is a softly-grouped panel
8071    // with a small heading and tidy body — gives the modal a clearer
8072    // reading rhythm than the previous flat list of titled chunks.
8073    section: {
8074      marginBottom: 12,
8075      paddingVertical: 10,
8076      paddingHorizontal: 11,
8077      borderRadius: 10,
8078      borderWidth: 1,
8079      borderColor: Palette.border,
8080      backgroundColor: Palette.bgElevated,
8081    },
8082    sectionTitle: {
8083      fontSize: 10,
8084      color: Palette.accentBright,
8085      fontWeight: '700',
8086      letterSpacing: 1,
8087      textTransform: 'uppercase',
8088      marginBottom: 6,
8089    },
8090    // Standalone heading used outside the bordered sections (e.g. the
8091    // "Supported intents" label above the two intent cards). Matches
8092    // sectionTitle typography but without the panel chrome.
8093    sectionTitleSolo: {
8094      fontSize: 10,
8095      color: Palette.accentBright,
8096      fontWeight: '700',
8097      letterSpacing: 1,
8098      textTransform: 'uppercase',
8099      marginTop: 4,
8100      marginBottom: 2,
8101      paddingHorizontal: 2,
8102    },
8103    bulletRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 8, paddingVertical: 2 },
8104    bullet: { fontSize: 13, color: Palette.accentBright, lineHeight: 17, marginTop: -1 },
8105    bulletText: { flex: 1, fontSize: 11, color: Palette.text, lineHeight: 17 },
8106    scoreLine: { fontSize: 11, color: Palette.text },
8107    scoreBig: { fontSize: 16, color: Palette.highlight, fontWeight: '700' },
8108    scoreVersion: { fontSize: 10, color: Palette.textMuted, marginTop: 2, fontFamily: Fonts?.mono },
8109  
8110    // Top-of-modal two-column score breakdown.
8111    scoreBreakdown: {
8112      flexDirection: 'row',
8113      gap: 10,
8114      marginBottom: 12,
8115      paddingBottom: 12,
8116      borderBottomWidth: 1,
8117      borderBottomColor: Palette.border,
8118    },
8119    scoreCol: { flex: 1, gap: 6 },
8120    // Hairline separating the Security / Privacy columns — gives the
8121    // two-up breakdown a cleaner seam than relying on the gap alone.
8122    scoreColDivider: {
8123      width: 1,
8124      alignSelf: 'stretch',
8125      backgroundColor: Palette.border,
8126      marginHorizontal: 2,
8127    },
8128    scoreColHeader: { flexDirection: 'row', alignItems: 'center', gap: 5, marginBottom: 2 },
8129    scoreColLabel: {
8130      fontSize: 10, color: Palette.accentBright, fontWeight: '700',
8131      letterSpacing: 0.8, textTransform: 'uppercase', flex: 1,
8132    },
8133    scoreColTotal: { fontSize: 18, color: Palette.highlight, fontWeight: '700' },
8134    scoreColScale: { fontSize: 10, color: Palette.textMuted, fontWeight: '600' },
8135    // Bar-style component score row: label + value on top, 0–100 fill,
8136    // optional note below.
8137    scoreBarRow: { gap: 2, marginBottom: 6 },
8138    scoreBarHeader: {
8139      flexDirection: 'row',
8140      alignItems: 'baseline',
8141      justifyContent: 'space-between',
8142    },
8143    scoreBarLabel: { fontSize: 11, color: Palette.text, fontWeight: '600' },
8144    scoreBarValue: {
8145      fontSize: 12,
8146      color: Palette.text,
8147      fontWeight: '700',
8148      fontVariant: ['tabular-nums'],
8149    },
8150    scoreBarTrack: {
8151      height: 4,
8152      borderRadius: 2,
8153      backgroundColor: Palette.border,
8154      overflow: 'hidden',
8155    },
8156    scoreBarFill: {
8157      height: 4,
8158      borderRadius: 2,
8159    },
8160    scoreBarNote: { fontSize: 10, color: Palette.textMuted, lineHeight: 13, marginTop: 2 },
8161  
8162    // privacytests.org per-category breakdown — label, mini-bar, ratio.
8163    testCategoryGrid: { gap: 4, marginTop: 8 },
8164    testCategoryRow: {
8165      flexDirection: 'row',
8166      alignItems: 'center',
8167      gap: 8,
8168    },
8169    testCategoryLabel: {
8170      width: 92,
8171      fontSize: 10,
8172      color: Palette.text,
8173    },
8174    testCategoryTrack: {
8175      flex: 1,
8176      height: 4,
8177      borderRadius: 2,
8178      backgroundColor: Palette.border,
8179      overflow: 'hidden',
8180    },
8181    testCategoryFill: {
8182      height: 4,
8183      borderRadius: 2,
8184    },
8185    testCategoryRatio: {
8186      fontSize: 10,
8187      color: Palette.textMuted,
8188      fontVariant: ['tabular-nums'],
8189      width: 36,
8190      textAlign: 'right',
8191    },
8192    scorePart: { flexDirection: 'row', gap: 8, alignItems: 'flex-start' },
8193    scorePartScore: {
8194      fontSize: 13, color: Palette.text, fontWeight: '700',
8195      width: 28, textAlign: 'right', fontVariant: ['tabular-nums'],
8196    },
8197    scorePartText: { flex: 1 },
8198    scorePartLabel: { fontSize: 11, color: Palette.text, fontWeight: '600' },
8199    scorePartNote: { fontSize: 10, color: Palette.textMuted, lineHeight: 13, marginTop: 1 },
8200  });
8201  
8202  export default function App() {
8203    return (
8204      <SafeAreaProvider>
8205        <Home />
8206        <StatusBar style="light" />
8207      </SafeAreaProvider>
8208    );
8209  }
8210  
8211  const styles = StyleSheet.create({
8212    root: { flex: 1, backgroundColor: Palette.bg },
8213    // Spans the ScrollView content so taps on empty regions fall
8214    // through to Keyboard.dismiss. flex:1 + width:100% so it claims
8215    // the full scrollable area on tall and short content alike.
8216    scrollDismissArea: { flex: 1, width: '100%' },
8217    // flexGrow lets the inner centered-stack view claim leftover vertical
8218    // space when content is shorter than the viewport (so LINK/FLOWS/NEW
8219    // FLOW sit visually centered). When content overflows the viewport,
8220    // the ScrollView takes over and things scroll normally.
8221    content: { paddingHorizontal: 16, paddingBottom: 40, flexGrow: 1 },
8222    // Center the whole block (LINK + FLOWS + NEW FLOW) vertically in
8223    // the leftover viewport height below the top bar. Tried split
8224    // top/bottom — the empty middle felt dead, so we cluster the two
8225    // groups together and let them float in the middle as one unit.
8226    // flex:1 + justifyContent center, paired with content's flexGrow:1
8227    // on the ScrollView container.
8228    // Centered block for LINK + FLOWS + NEW FLOW. flex:1 +
8229    // justifyContent center claims the leftover viewport and groups
8230    // all three zones in the middle. Upward bias (paddingBottom) is
8231    // set inline because it varies with viewport height — see
8232    // centerBiasPadding in Home().
8233    // flex-start so content sits high under the meta row without
8234    // percentage-padding gymnastics. Earlier we tried justifyContent
8235    // center + paddingBottom: '85%' to push content up, but the
8236    // percentage makes zoneStack self-expand to ~6.7× content height
8237    // (since the 15% non-padded area must fit all children), which
8238    // overflows the viewport and turns the ScrollView scrollable
8239    // even when content fits. flex-start is deterministic.
8240    zoneStack: { flex: 1, justifyContent: 'flex-start' },
8241    // Semantic wrappers only — the parent's center axis groups them.
8242    // LINK section wrapper. relative positioning + a hair of padding
8243    // so the corner brackets (absolutely positioned children) sit just
8244    // outside the inner content rather than overlapping it.
8245    topGroup: {
8246      position: 'relative',
8247      paddingHorizontal: 8,
8248      paddingVertical: 6,
8249      // Extra air between the metaRow above (Settings / Default
8250      // Browser) and the LINK section scope — gives the navbar room
8251      // to breathe before the framed input region starts.
8252      marginTop: 18,
8253    },
8254    // Corner brackets ("scope" marks) — each is an L-shape made of two
8255    // touching borders. Length 12dp, accentDeep stroke so they read as
8256    // structural rails, not decoration.
8257    scopeCorner: {
8258      position: 'absolute',
8259      width: 12,
8260      height: 12,
8261      borderColor: Palette.accentDeep,
8262    },
8263    // Each L-shape gets a radius on its outer corner so the brackets
8264    // curl gently instead of meeting at a sharp 90°. Reads as a softer
8265    // "viewfinder" framing the LINK region.
8266    scopeCornerTL: {
8267      top: 0, left: 0,
8268      borderTopWidth: 1, borderLeftWidth: 1,
8269      borderTopLeftRadius: 6,
8270    },
8271    scopeCornerTR: {
8272      top: 0, right: 0,
8273      borderTopWidth: 1, borderRightWidth: 1,
8274      borderTopRightRadius: 6,
8275    },
8276    scopeCornerBL: {
8277      bottom: 0, left: 0,
8278      borderBottomWidth: 1, borderLeftWidth: 1,
8279      borderBottomLeftRadius: 6,
8280    },
8281    scopeCornerBR: {
8282      bottom: 0, right: 0,
8283      borderBottomWidth: 1, borderRightWidth: 1,
8284      borderBottomRightRadius: 6,
8285    },
8286    // FLOWS section wrapper — same scoping pattern as topGroup so the
8287    // two regions read as visually paired (each with its own corner
8288    // brackets). Top padding leaves room for the centered "Flows"
8289    // caption to sit on the top border without overlapping the list.
8290    bottomGroup: {
8291      position: 'relative',
8292      paddingHorizontal: 2,
8293      paddingVertical: 6,
8294      // Section gap is now provided by the rune divider above, so
8295      // the wrapper itself doesn't need extra top margin.
8296    },
8297    // Centered caption that breaks the top bracket line — sits on the
8298    // border with the page bg painted behind it so the corner-bracket
8299    // strokes don't overlap the text. Reads as a frame plate.
8300    scopeTopLabelWrap: {
8301      position: 'absolute',
8302      top: -7,
8303      left: 0,
8304      right: 0,
8305      alignItems: 'center',
8306    },
8307    scopeTopLabel: {
8308      backgroundColor: Palette.bg,
8309      paddingHorizontal: 8,
8310      fontSize: 10,
8311      color: Palette.textMuted,
8312      fontWeight: '700',
8313      letterSpacing: 1.5,
8314      textTransform: 'uppercase',
8315    },
8316  
8317    topBar: {
8318      // One-row navbar: brand left, controls right, separated by
8319      // space-between. Hairline finishes the bottom edge.
8320      flexDirection: 'row',
8321      alignItems: 'center',
8322      justifyContent: 'space-between',
8323      paddingBottom: 10,
8324      marginBottom: 8,
8325      borderBottomWidth: 1,
8326      borderBottomColor: Palette.border,
8327    },
8328    // Settings shelf under the hairline — single left-aligned control.
8329    metaRow: {
8330      flexDirection: 'row',
8331      alignItems: 'center',
8332      justifyContent: 'space-between',
8333      marginBottom: 10,
8334    },
8335    // CTA for the "make me default" state. Shield + text styled
8336    // identically to the Settings affordance next to it (same
8337    // textMuted tone, same weight, same icon size) so they read as
8338    // a matched pair of navbar controls. The only thing setting
8339    // this one apart is the glowing border — a quiet halo says
8340    // "act on this" without changing the typographic voice.
8341    setDefaultCtaGlow: {
8342      flexDirection: 'row',
8343      alignItems: 'center',
8344      gap: 5,
8345      paddingVertical: 4,
8346      paddingHorizontal: 8,
8347      borderRadius: 8,
8348      borderWidth: 1,
8349      borderColor: Palette.accent,
8350      boxShadow: '0 0 6px rgba(184,212,154,0.40)',
8351    },
8352    setDefaultCtaGlowText: {
8353      fontSize: 12,
8354      color: Palette.textMuted,
8355      fontWeight: '600',
8356    },
8357    brandTitleRow: {
8358      flexDirection: 'row',
8359      alignItems: 'center',
8360      gap: 8,
8361    },
8362    // Small unobtrusive gear button that opens the Settings sheet. Lives
8363    // to the left of the default-browser pill so the user always has a
8364    // path to global preferences without needing a separate menu.
8365    // Icon + label — gear sits next to the default-status pill in
8366    // the navbar right cluster. Padding gives a generous press
8367    // target without needing a fixed square.
8368    // Bordered pill — same geometry (padding + radius + border
8369    // width) as the Set-as-Default CTA next to it so the two
8370    // buttons sit at identical height. Distinguishes via the
8371    // muted border colour + no glow.
8372    settingsIconBtn: {
8373      flexDirection: 'row',
8374      alignItems: 'center',
8375      gap: 5,
8376      paddingVertical: 4,
8377      paddingHorizontal: 8,
8378      borderRadius: 8,
8379      borderWidth: 2,
8380      borderColor: Palette.border,
8381    },
8382    settingsIconLabel: {
8383      fontSize: 12,
8384      color: Palette.textMuted,
8385      fontWeight: '600',
8386    },
8387    // Circle frame around the Dagaz glyph. Even size + integer
8388    // border so the ring stays a clean circle on Android.
8389    settingsRuneRing: {
8390      width: 22,
8391      height: 22,
8392      borderRadius: 11,
8393      borderWidth: 1,
8394      borderColor: Palette.textMuted,
8395      alignItems: 'center',
8396      justifyContent: 'center',
8397    },
8398    settingsRuneGlyph: {
8399      fontSize: 13,
8400      color: Palette.textMuted,
8401      fontWeight: '500',
8402      // includeFontPadding off + lineHeight matches ring height so
8403      // the rune visually centers regardless of the font's intrinsic
8404      // baseline offset.
8405      lineHeight: 16,
8406      includeFontPadding: false,
8407      textAlignVertical: 'center',
8408    },
8409    // Subtle "Default" indicator — same geometry as the ratingPill / urlActionPill
8410    // vocabulary used elsewhere. Not a CTA, just a quiet confirmation.
8411    defaultPill: {
8412      flexDirection: 'row',
8413      alignItems: 'center',
8414      gap: 4,
8415      paddingHorizontal: 8,
8416      paddingVertical: 3,
8417      borderRadius: 999,
8418      borderWidth: 1,
8419      borderColor: Palette.border,
8420      backgroundColor: Palette.bgElevated,
8421    },
8422    defaultPillText: { fontSize: 11, color: Palette.accentBright, fontWeight: '700', letterSpacing: 0.3 },
8423    brandRow: { flexDirection: 'row', alignItems: 'center', gap: 10 },
8424    // Subtle press feedback so the whole title area reads as tappable
8425    // without adding a chrome that competes with the wordmark.
8426    brandRowPressed: { opacity: 0.6 },
8427    brandMark: {
8428      width: 32,
8429      height: 32,
8430      // Lets the PNG scale crisply at any density — the source is 256×256
8431      // (8x the displayed size).
8432      resizeMode: 'contain',
8433    },
8434    brandText: { flexDirection: 'column', alignItems: 'flex-start' },
8435    brandName: {
8436      fontFamily: Fonts?.serif,
8437      fontSize: 18,
8438      color: Palette.text,
8439      fontWeight: '500',
8440      letterSpacing: 0.2,
8441    },
8442    brandNameAccent: { color: Palette.highlight, fontStyle: 'italic' },
8443    // Slogan tucked between the wordmark and the version row.
8444    brandSlogan: {
8445      fontSize: 11,
8446      color: Palette.textMuted,
8447      fontStyle: 'italic',
8448      letterSpacing: 0.3,
8449      marginTop: 1,
8450    },
8451    versionRow: { flexDirection: 'row', alignItems: 'center', gap: 6, opacity: 0.7, marginTop: 2 },
8452    // Compact version tag tucked beneath the Warden wordmark in
8453    // the brand column. Small, muted, mono so it reads as metadata
8454    // rather than competing with the brand.
8455    brandVersionInline: {
8456      fontSize: 10,
8457      color: Palette.textMuted,
8458      fontFamily: Fonts?.mono,
8459      fontWeight: '600',
8460      marginTop: 1,
8461      opacity: 0.7,
8462    },
8463    versionTag: { fontSize: 11, color: Palette.accentBright, fontFamily: Fonts?.mono, fontWeight: '600' },
8464    versionDot: { fontSize: 11, color: Palette.textMuted },
8465    versionCommit: { fontSize: 11, color: Palette.textMuted, fontFamily: Fonts?.mono },
8466    // Tappable "About" link in the version row — replaces the freestanding
8467    // (i) info chip. Accent-coloured so the affordance is discoverable
8468    // without shouting.
8469    versionAboutLink: {
8470      fontSize: 11,
8471      color: Palette.accentBright,
8472      fontWeight: '600',
8473      textDecorationLine: 'underline',
8474      textDecorationColor: Palette.accentBright,
8475    },
8476  
8477    kicker: { fontSize: 10, letterSpacing: 2, textTransform: 'uppercase', color: Palette.accentBright, fontWeight: '600' },
8478    titleRow: {
8479      flexDirection: 'row',
8480      alignItems: 'center',
8481      marginTop: 4,
8482      marginBottom: 14,
8483      gap: 10,
8484    },
8485    title: { fontFamily: Fonts?.serif, fontSize: 26, color: Palette.text, flex: 1, fontWeight: '400' },
8486    em: { fontStyle: 'italic', color: Palette.highlight },
8487  
8488    // Layout B (quiet rails) — small caps section marker that anchors
8489    // each zone (LINK, BROWSERS, NEW FLOW) along the left edge.
8490    // Margins kept tight because LINK sits inline with the tools row;
8491    // BROWSERS / NEW FLOW labels use a wrapper to add their own
8492    // breathing room below.
8493    zoneLabel: {
8494      fontSize: 9,
8495      color: Palette.accentBright,
8496      fontWeight: '700',
8497      letterSpacing: 1.5,
8498    },
8499    // Wrapper for the LINK zone label when it sits inline with the
8500    // testsBtn (Presets) pill. Mirrors the pill's paddingVertical so
8501    // both flex children have identical box dimensions, which lets
8502    // `alignItems: 'center'` produce a clean visual alignment without
8503    // chasing Android's font-padding quirks.
8504    linkLabelBox: {
8505      paddingVertical: 3,
8506      paddingHorizontal: 0,
8507      alignItems: 'center',
8508      justifyContent: 'center',
8509    },
8510    // Standalone zone label (FLOWS, NEW FLOW) — sits on its own line,
8511    // not inline with a sibling pill. Generous breathing room above
8512    // to separate from the previous zone + a small marginBottom so
8513    // the next element doesn't crowd the caps.
8514    zoneLabelStandalone: {
8515      marginTop: 30,
8516      marginBottom: 6,
8517      // Soft indent so the title clears the screen edge and reads as
8518      // a header rather than a hard-left caps row. Matches the
8519      // urlToolsPanel inset above for consistent left rhythm.
8520      marginLeft: 6,
8521    },
8522    // Hairline horizontal divider used between zones. Asymmetric
8523    // margins: less above (the line above is the URL status which has
8524    // tight padding already), more below to push the next zone label
8525    // clear of the line.
8526    zoneDivider: {
8527      height: 1,
8528      backgroundColor: Palette.border,
8529      marginTop: 6,
8530      marginBottom: 12,
8531    },
8532    // Tools row + URL field stripped of card chrome so the section
8533    // reads as one continuous zone. The field keeps a softly elevated
8534    // background + rounded border so it still presents as a clearly
8535    // tappable input box.
8536    urlToolsPanel: {
8537      flexDirection: 'row',
8538      justifyContent: 'space-between',
8539      alignItems: 'center',
8540      // Small left/right indent so the LINK label + Presets cluster
8541      // don't crash against the screen edge. Doesn't fully match the
8542      // URL-field's inner padding (12) but reads as "soft alignment"
8543      // without making the tools row feel pushed-in.
8544      paddingHorizontal: 6,
8545      // No top margin — the LINK section's corner brackets frame the
8546      // tools row directly, so any extra top space here would push
8547      // the content visually off-axis from the top brackets.
8548      marginTop: 0,
8549      marginBottom: 8,
8550    },
8551    urlFieldPanel: {
8552      backgroundColor: Palette.bgElevated,
8553      borderWidth: 1,
8554      borderColor: Palette.border,
8555      borderRadius: 10,
8556      paddingVertical: 10,
8557      paddingHorizontal: 12,
8558      marginBottom: 6,
8559    },
8560    urlHeaderLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 },
8561    // Paste / Clear / Share share one container that claims the
8562    // remaining width to the right of LINK + Presets. flex-end pulls
8563    // the whole cluster to the right edge; the fixed gap keeps the
8564    // three pills tightly packed instead of spreading across half
8565    // the row.
8566    urlHeaderRightGroup: {
8567      flex: 1,
8568      flexDirection: 'row',
8569      alignItems: 'center',
8570      justifyContent: 'flex-end',
8571      gap: 16,
8572    },
8573    urlLabel: { fontSize: 9, letterSpacing: 1.5, color: Palette.accentBright, fontWeight: '600' },
8574    testsBtn: {
8575      // Matches urlActionPill (Paste / Clear / Share) so all four
8576      // tools-row buttons share identical geometry: gap 3, padding 2/7,
8577      // pill radius, muted border, bgElevated fill.
8578      flexDirection: 'row', alignItems: 'center', gap: 3,
8579      paddingVertical: 2, paddingHorizontal: 7,
8580      borderRadius: 999, backgroundColor: Palette.bgElevated,
8581      borderWidth: 1, borderColor: Palette.border,
8582    },
8583    testsBtnPressed: { opacity: 0.7 },
8584    testsBtnText: { fontSize: 11, color: Palette.accentBright, fontWeight: '700' },
8585    testsBox: {
8586      backgroundColor: Palette.surfaceDim,
8587      borderWidth: 1,
8588      borderColor: Palette.border,
8589      borderRadius: 10,
8590      paddingVertical: 8,
8591      paddingHorizontal: 10,
8592      marginBottom: 10,
8593      gap: 2,
8594    },
8595    // Breathing room between groups in the Presets modal.
8596    presetGroupSpacer: { marginTop: 12 },
8597    // ─── Inline Custom-presets section in PresetsModal ───────────────
8598    // Bordered panel wrapping the entire Custom section. Same chrome
8599    // values as the group-card variant (testsRowGroup) so the nav group
8600    // and the user's custom list read as visual peers.
8601    presetCustomPanel: {
8602      marginTop: 10,
8603      paddingVertical: 10,
8604      paddingHorizontal: 10,
8605      borderRadius: 10,
8606      borderWidth: 1,
8607      borderColor: Palette.border,
8608      backgroundColor: Palette.bgElevated,
8609    },
8610    // Small lowercase hint sitting just under the caps "CUSTOM" header
8611    // — surfaces the long-press affordance without crowding the row.
8612    // Empty-state CTA shown in place of the row list when the user has
8613    // no custom URLs yet. Centered + larger than the header chip so
8614    // there's one obvious thing to do.
8615    presetCustomEmptyCta: {
8616      flexDirection: 'row',
8617      alignItems: 'center',
8618      justifyContent: 'center',
8619      gap: 6,
8620      alignSelf: 'center',
8621      marginTop: 6,
8622      marginBottom: 4,
8623      paddingVertical: 8,
8624      paddingHorizontal: 16,
8625      borderRadius: 999,
8626      borderWidth: 1,
8627      borderColor: Palette.border,
8628      backgroundColor: Palette.bgElevated,
8629    },
8630    presetCustomEmptyCtaText: {
8631      fontSize: 12,
8632      color: Palette.highlight,
8633      fontWeight: '700',
8634      letterSpacing: 0.3,
8635    },
8636    // Quiet long-press affordance hint shown only when there's at least
8637    // one custom row to actually long-press on.
8638    presetCustomFootHint: {
8639      fontSize: 10,
8640      color: Palette.textMuted,
8641      fontStyle: 'italic',
8642      paddingHorizontal: 4,
8643      marginTop: 6,
8644      textAlign: 'center',
8645    },
8646    presetCustomAddRow: {
8647      flexDirection: 'row',
8648      alignItems: 'center',
8649      gap: 8,
8650      marginTop: 6,
8651      paddingVertical: 7,
8652      paddingLeft: 12,
8653      paddingRight: 6,
8654      borderRadius: 9,
8655      borderWidth: 1,
8656      borderStyle: 'dashed',
8657      borderColor: Palette.border,
8658      backgroundColor: Palette.bg,
8659    },
8660    presetCustomInput: {
8661      flex: 1,
8662      fontSize: 12,
8663      color: Palette.text,
8664      paddingVertical: 0,
8665      paddingHorizontal: 0,
8666    },
8667    presetCustomAddBtn: {
8668      width: 28,
8669      height: 28,
8670      alignItems: 'center',
8671      justifyContent: 'center',
8672      borderRadius: 14,
8673      borderWidth: 1,
8674      borderColor: Palette.highlight,
8675      backgroundColor: Palette.bgElevated,
8676    },
8677    presetCustomError: {
8678      fontSize: 11,
8679      color: '#d4a04a',
8680      fontStyle: 'italic',
8681      marginTop: 6,
8682      paddingHorizontal: 4,
8683    },
8684    // Custom-panel header row — anchors the Add URL chip to the right
8685    // edge of the panel when the composer isn't active. (The "Custom"
8686    // caps label was dropped now that the tab strip carries the same
8687    // word.)
8688    presetCustomHeaderRow: {
8689      flexDirection: 'row',
8690      justifyContent: 'flex-end',
8691      marginBottom: 4,
8692    },
8693    // Compact "+ Add URL" chip pinned to the right of the Custom panel
8694    // header. Tap-to-expand reveals the composer below the items.
8695    presetCustomAddChip: {
8696      flexDirection: 'row',
8697      alignItems: 'center',
8698      gap: 4,
8699      paddingHorizontal: 8,
8700      paddingVertical: 4,
8701      borderRadius: 999,
8702      borderWidth: 1,
8703      borderColor: Palette.highlight,
8704      backgroundColor: Palette.bgElevated,
8705    },
8706    presetCustomAddChipText: {
8707      fontSize: 10,
8708      color: Palette.highlight,
8709      fontWeight: '700',
8710      letterSpacing: 0.5,
8711      textTransform: 'uppercase',
8712    },
8713    testsBoxLabel: {
8714      fontSize: 10,
8715      color: Palette.textMuted,
8716      letterSpacing: 0.5,
8717      marginBottom: 4,
8718      paddingHorizontal: 4,
8719      textTransform: 'uppercase',
8720      marginTop: 6,
8721    },
8722    testsDivider: {
8723      height: 1,
8724      backgroundColor: Palette.border,
8725      marginVertical: 4,
8726    },
8727    presetsScroll: {
8728      maxHeight: 380,
8729      marginTop: 6,
8730    },
8731    presetsScrollContent: {
8732      paddingVertical: 10,
8733      paddingHorizontal: 10,
8734    },
8735    testsRow: {
8736      flexDirection: 'row',
8737      alignItems: 'center',
8738      paddingVertical: 8,
8739      paddingHorizontal: 10,
8740      marginVertical: 2,
8741      borderRadius: 8,
8742      borderWidth: 1,
8743      borderColor: Palette.border,
8744      backgroundColor: Palette.bg,
8745      gap: 10,
8746    },
8747    testsRowPressed: { backgroundColor: Palette.surface },
8748    // Forward-nav row variant — used by group rows that drill into a
8749    // sub-list. Bordered + elevated bg signals "tappable card with more
8750    // inside" rather than a flat list item.
8751    testsRowGroup: {
8752      paddingVertical: 11,
8753      paddingHorizontal: 12,
8754      borderRadius: 10,
8755      borderWidth: 1,
8756      borderColor: Palette.border,
8757      backgroundColor: Palette.bgElevated,
8758      marginTop: 4,
8759    },
8760    presetIcon: { width: 16 },
8761    testsRowText: { flex: 1 },
8762    testsRowLabel: { fontSize: 13, color: Palette.text, fontWeight: '500' },
8763    testsRowHint: { fontSize: 11, color: Palette.textMuted, marginTop: 1 },
8764    urlActions: { flexDirection: 'row', gap: 6 },
8765    iconBtn: {
8766      width: 28, height: 28, borderRadius: 8, alignItems: 'center', justifyContent: 'center',
8767      backgroundColor: Palette.bgElevated,
8768    },
8769    iconBtnPressed: { opacity: 0.7, transform: [{ scale: 0.95 }] },
8770    iconBtnDisabled: { opacity: 0.35 },
8771    // Pill style for the URL field's Clear / Paste / Share actions. Matches
8772    // the `ratingPill` "browser checks" geometry — rounded 999, hairline
8773    // border, elevated bg, tiny icon + tiny text — so the three sit
8774    // visually alongside the score pills as the same vocabulary.
8775    urlActionPill: {
8776      flexDirection: 'row',
8777      alignItems: 'center',
8778      gap: 3,
8779      paddingHorizontal: 7,
8780      paddingVertical: 2,
8781      borderRadius: 999,
8782      borderWidth: 1,
8783      borderColor: Palette.border,
8784      backgroundColor: Palette.bgElevated,
8785    },
8786    urlActionPillText: { fontSize: 11, color: Palette.accentBright, fontWeight: '700' },
8787    // Applied to BOTH the Share Source pill (URL tools row) and the
8788    // Share Final pill (status row) so the two mirror each other —
8789    // identical width + center-anchored content. The wider of the two
8790    // labels ("Share Source" at 12 chars) drives the minWidth; the
8791    // other pill stretches to match.
8792  
8793    // Yellow-glow "you need to do something" CTA. Only rendered when Warden
8794    // is NOT the default browser. The yellow (`#f6c84c`) is the same hue we
8795    // use for the Autolaunch indicator, so attention-grabbing markers in the
8796    // UI share a vocabulary. boxShadow gives the actual halo on RN 0.76+
8797    // (works on Android via the new arch); textShadow reinforces the glow on
8798    // the label itself for devices where boxShadow lands subtly.
8799    setDefaultCta: {
8800      flexDirection: 'row',
8801      alignItems: 'center',
8802      paddingVertical: 6,
8803      paddingHorizontal: 12,
8804      borderRadius: 999,
8805      borderWidth: 1.5,
8806      borderColor: '#f6c84c',
8807      backgroundColor: 'rgba(246, 200, 76, 0.08)',
8808      // @ts-ignore — boxShadow is supported on RN 0.76+ but the type ships later.
8809      boxShadow: '0 0 12px rgba(246, 200, 76, 0.55)',
8810    },
8811    setDefaultCtaText: {
8812      fontSize: 12,
8813      color: '#f6c84c',
8814      fontWeight: '700',
8815      letterSpacing: 0.3,
8816      textShadowColor: 'rgba(246, 200, 76, 0.75)',
8817      textShadowOffset: { width: 0, height: 0 },
8818      textShadowRadius: 6,
8819    },
8820    statusPressed: { opacity: 0.7, transform: [{ scale: 0.97 }] },
8821    urlText: { fontSize: 13, color: Palette.text, fontFamily: Fonts?.mono },
8822    urlInput: { fontSize: 13, color: Palette.text, fontFamily: Fonts?.mono, padding: 0, minHeight: 20 },
8823    // Shown in place of the URL field when the Browser-config preset is
8824    // active. Reads as a tag — the user can't edit because each Flow has
8825    // its own browser-specific URL surfaced on the right side of the row.
8826    urlConfigBanner: {
8827      flexDirection: 'row',
8828      alignItems: 'center',
8829      gap: 7,
8830      paddingVertical: 5,
8831    },
8832    urlConfigBannerText: {
8833      fontSize: 12,
8834      color: Palette.textMuted,
8835      fontStyle: 'italic',
8836      flex: 1,
8837    },
8838  
8839    // Generic press feedback for tappable rows that aren't part of a dedicated
8840    // button style (e.g. the New-Flow button).
8841    tabPressed: { opacity: 0.7 },
8842  
8843    emptyHint: {
8844      fontSize: 12,
8845      color: Palette.textMuted,
8846      textAlign: 'center',
8847      paddingVertical: 14,
8848      letterSpacing: 0.5,
8849      fontStyle: 'italic',
8850    },
8851    // Softly glowing card — solid sage-tinted border + outer halo so
8852    // the empty state reads as inviting / "something is about to
8853    // happen here" rather than a dashed dropzone.
8854    onboardingCard: {
8855      alignItems: 'center',
8856      paddingVertical: 24,
8857      paddingHorizontal: 18,
8858      gap: 8,
8859      borderWidth: 1,
8860      borderColor: Palette.accentDeep,
8861      borderRadius: 14,
8862      backgroundColor: Palette.surfaceDim,
8863      boxShadow: '0 0 18px rgba(184,212,154,0.22)',
8864    },
8865    // Large rune glyph carries its own halo — the sage textShadow
8866    // pushes the brand's mystical / inlay aesthetic without dragging
8867    // in any actual graphics.
8868    onboardingRune: {
8869      fontSize: 32,
8870      color: Palette.highlight,
8871      fontWeight: '500',
8872      letterSpacing: 2,
8873      lineHeight: 38,
8874      textShadowColor: 'rgba(184,212,154,0.55)',
8875      textShadowOffset: { width: 0, height: 0 },
8876      textShadowRadius: 10,
8877    },
8878    onboardingTitle: {
8879      fontSize: 14,
8880      color: Palette.text,
8881      fontWeight: '700',
8882      letterSpacing: 0.3,
8883    },
8884    onboardingBody: {
8885      fontSize: 12,
8886      color: Palette.textMuted,
8887      lineHeight: 17,
8888      textAlign: 'center',
8889    },
8890    onboardingCardPressed: { opacity: 0.75 },
8891    // Breathing room above the New Flow button now that the NEW FLOW
8892    // zone marker has been removed. Mirrors the old marginTop the
8893    // label carried, so the visual rhythm between Flows list and the
8894    // button stays the same.
8895    newProfileBtnAfterFlows: { marginTop: 28 },
8896    // Deliberately small. The big tap targets on this screen are the
8897    // per-browser launch buttons; both New Flow (rare config) and
8898    // Share URL (rare outbound) are kept compact and spread to opposite
8899    // edges of their row so they don't compete for thumb real estate.
8900    bottomActionRow: {
8901      position: 'relative',
8902      flexDirection: 'row',
8903      alignItems: 'center',
8904      justifyContent: 'space-between',
8905      paddingHorizontal: 5,
8906      paddingVertical: 4,
8907    },
8908    // Rune divider between FLOWS section and bottom action row. The
8909    // two hairlines flank a small "ᛟ ᛉ ᛟ" carving — algiz (protection,
8910    // the closest rune to a shield motif) framed by othala. Drawn in
8911    // accentDeep so it reads as quiet inlay, not flashy ornament.
8912    runeDivider: {
8913      flexDirection: 'row',
8914      alignItems: 'center',
8915      marginVertical: 22,
8916      paddingHorizontal: 12,
8917    },
8918    // Standalone variant — single centered glyph, no flanking rails.
8919    // Used for the quieter Settings ↔ LINK divider.
8920    runeDividerSolo: {
8921      alignItems: 'center',
8922      marginVertical: 10,
8923    },
8924    // Equal flex:1 on both flanks centers the rune glyph between them.
8925    runeDividerLine: {
8926      flex: 1,
8927      height: 1,
8928      backgroundColor: Palette.accentDeep,
8929      opacity: 0.5,
8930    },
8931    runeDividerGlyph: {
8932      marginHorizontal: 14,
8933      color: Palette.accentDeep,
8934      fontSize: 14,
8935      letterSpacing: 2,
8936      fontWeight: '500',
8937      textAlign: 'center',
8938    },
8939    newProfileBtn: {
8940      flexDirection: 'row',
8941      alignItems: 'center',
8942      gap: 5,
8943      paddingVertical: 6,
8944      paddingHorizontal: 10,
8945      borderRadius: 8,
8946      borderWidth: 1,
8947      // Muted border — secondary affordance, doesn't steal attention
8948      // from the per-browser Open buttons above.
8949      borderColor: Palette.border,
8950      backgroundColor: Palette.bgElevated,
8951    },
8952    newProfileBtnText: {
8953      fontSize: 11,
8954      color: Palette.accentBright,
8955      fontWeight: '700',
8956      letterSpacing: 0.3,
8957    },
8958    errorBox: {
8959      backgroundColor: '#3a1a1a', borderWidth: 1, borderColor: '#8a3a3a',
8960      borderRadius: 10, paddingVertical: 10, paddingHorizontal: 12, marginBottom: 10,
8961    },
8962    errorText: { fontSize: 12, color: '#f0a0a0', lineHeight: 16 },
8963  
8964    list: { gap: 12 },
8965    // Tightened gap between the LINK section and the flows list now
8966    // that the FLOWS zone marker has been dropped — just enough air
8967    // to read as a section break, not a full chapter divider.
8968    // Old marginTop:28 used to separate the flow list from the LINK
8969    // section above. The gap now lives on bottomGroup (marginTop) so
8970    // it's outside the corner brackets; this style is kept as a small
8971    // breath between the "Flows" caption and the first row.
8972    flowsListAfterLink: { marginTop: 4 },
8973  
8974    // Profile tab selector
8975    profileTabs: {
8976      flexDirection: 'row',
8977      gap: 4,
8978      marginBottom: 10,
8979      paddingHorizontal: 2,
8980    },
8981    profileTab: {
8982      flexDirection: 'row',
8983      alignItems: 'center',
8984      gap: 5,
8985      paddingVertical: 5,
8986      paddingHorizontal: 10,
8987      borderRadius: 8,
8988      borderWidth: 1,
8989      borderColor: Palette.border,
8990      backgroundColor: 'transparent',
8991    },
8992    profileTabActive: {
8993      borderColor: Palette.accent,
8994      backgroundColor: Palette.accent + '18',
8995    },
8996    profileTabText: {
8997      fontSize: 11,
8998      fontWeight: '600',
8999      color: Palette.textMuted,
9000      letterSpacing: 0.2,
9001    },
9002    profileTabTextActive: {
9003      color: Palette.accentBright,
9004    },
9005    profileTabCount: {
9006      minWidth: 16,
9007      paddingVertical: 1,
9008      paddingHorizontal: 4,
9009      borderRadius: 8,
9010      backgroundColor: Palette.bgElevated,
9011      alignItems: 'center',
9012    },
9013    profileTabCountActive: {
9014      backgroundColor: Palette.accent + '30',
9015    },
9016    profileTabCountText: {
9017      fontSize: 10,
9018      fontWeight: '700',
9019      color: Palette.textMuted,
9020    },
9021    profileTabCountTextActive: {
9022      color: Palette.accentBright,
9023    },
9024  
9025    // Profile badge dot on flow rows
9026    profileBadge: {
9027      width: 6,
9028      height: 6,
9029      borderRadius: 3,
9030      marginRight: 6,
9031    },
9032    profileBadgeRaw: {
9033      backgroundColor: Palette.textMuted,
9034    },
9035    profileBadgeWork: {
9036      backgroundColor: '#f6c84c',
9037    },
9038  
9039    // Flow rows are split: left = title/meta (opens editor), right = launch.
9040    rowOuter: {
9041      flexDirection: 'row',
9042      alignItems: 'stretch',
9043      backgroundColor: Palette.surface,
9044      borderWidth: 1,
9045      borderColor: Palette.border,
9046      borderRadius: 12,
9047      overflow: 'hidden',
9048    },
9049    rowLeft: {
9050      flex: 1,
9051      flexDirection: 'row',
9052      alignItems: 'center',
9053      paddingVertical: 3,
9054      paddingHorizontal: 6,
9055    },
9056    rowRight: {
9057      // Opening part — meatier tap target. On a ~360dp phone width:
9058      // 130 lands ~36% of total row width, leaving the browser-info
9059      // side ~64%.
9060      width: 130,
9061      gap: 3,
9062      alignItems: 'center',
9063      justifyContent: 'center',
9064      // Stronger divider so the two halves read as distinct interactive
9065      // zones (left = edit, right = launch). Doubled stroke + bumped color
9066      // from `border` to `accentDeep` so it actually shows against the
9067      // surface tone; the right half's bg is also bumped one step darker.
9068      borderLeftWidth: 2,
9069      borderLeftColor: Palette.accentDeep,
9070      backgroundColor: Palette.bg,
9071    },
9072    rowRightLabel: {
9073      fontSize: 10,
9074      fontWeight: '700',
9075      letterSpacing: 1.2,
9076      textTransform: 'uppercase',
9077      color: Palette.accentBright,
9078      textAlign: 'center',
9079      lineHeight: 12,
9080    },
9081    rowPressed: { opacity: 0.85, transform: [{ scale: 0.99 }] },
9082    rowDisabled: { opacity: 0.35 },
9083    swatch: { width: 28, height: 28, borderRadius: 8, marginRight: 10 },
9084    icon: { width: 32, height: 32, borderRadius: 8, marginRight: 10, resizeMode: 'contain' },
9085    rowText: { flex: 1 },
9086    rowName: { flexShrink: 1, fontSize: 13, color: Palette.text, fontWeight: '500' },
9087    rowTag: { fontSize: 11, color: Palette.textMuted, marginTop: 1 },
9088    alwaysPrivateRow: { flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 2 },
9089    alwaysPrivateIcon: { fontSize: 11 },
9090    alwaysPrivateLabel: {
9091      fontSize: 10, color: Palette.highlight, letterSpacing: 0.8, fontWeight: '600', textTransform: 'uppercase',
9092    },
9093    rowNameRow: { flexDirection: 'row', alignItems: 'center', gap: 6, flexWrap: 'wrap' },
9094    // Free-form Tag pill next to the browser name. Same ratingPill /
9095    // urlActionPill vocabulary: rounded 999, hairline border, elevated bg.
9096    flowTagPill: {
9097      flexShrink: 1,
9098      paddingHorizontal: 8,
9099      paddingVertical: 2,
9100      borderRadius: 999,
9101      borderWidth: 1,
9102      borderColor: Palette.border,
9103      backgroundColor: Palette.bgElevated,
9104    },
9105    flowTagPillText: {
9106      fontSize: 11,
9107      color: Palette.accentBright,
9108      fontWeight: '600',
9109      letterSpacing: 0.2,
9110    },
9111    autolaunchBadge: {
9112      flexShrink: 0,
9113      width: 20,
9114      height: 20,
9115      alignItems: 'center',
9116      justifyContent: 'center',
9117      borderRadius: 999,
9118      borderWidth: 1,
9119      borderColor: '#f6c84c',
9120      backgroundColor: 'transparent',
9121    },
9122    modePillRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 3, marginTop: 3 },
9123    // Active modes render as plain uppercase labels joined by middot
9124    // separators — no pill chrome, just text. Reads as a sentence-y
9125    // "privacy profile" description rather than a row of tags.
9126    modeInlineText: {
9127      fontSize: 8,
9128      color: Palette.highlight,
9129      fontWeight: '700',
9130      letterSpacing: 0.6,
9131      textTransform: 'uppercase',
9132      alignSelf: 'center',
9133    },
9134    modeInlineSep: {
9135      color: Palette.accentDeep,
9136      fontWeight: '400',
9137    },
9138    // Same shape as modeInlineText, but muted — used for the "Normal"
9139    // resting state so it reads as secondary while staying flush-left
9140    // with the browser name above (no pill chrome to inset it).
9141    modeInlineTextMuted: {
9142      fontSize: 8,
9143      color: Palette.textMuted,
9144      fontWeight: '700',
9145      letterSpacing: 0.6,
9146      textTransform: 'uppercase',
9147      alignSelf: 'center',
9148    },
9149    modePill: {
9150      flexDirection: 'row',
9151      alignItems: 'center',
9152      gap: 2,
9153      paddingHorizontal: 5,
9154      paddingVertical: 1,
9155      borderRadius: 999,
9156      borderWidth: 1,
9157      borderColor: Palette.highlight,
9158      backgroundColor: 'transparent',
9159    },
9160    modePillText: {
9161      fontSize: 8, color: Palette.highlight, fontWeight: '700',
9162      letterSpacing: 0.3, textTransform: 'uppercase',
9163    },
9164    modePillMuted: {
9165      paddingHorizontal: 5,
9166      paddingVertical: 1,
9167      borderRadius: 999,
9168      borderWidth: 1,
9169      borderColor: Palette.border,
9170      backgroundColor: 'transparent',
9171    },
9172    modePillTextMuted: {
9173      fontSize: 8, color: Palette.textMuted, fontWeight: '700',
9174      letterSpacing: 0.3, textTransform: 'uppercase',
9175    },
9176    // Status column: two stacked lines.
9177    //  - Top line (urlHintStatusLine) — state label on the left, cycle +
9178    //    Share Final actions on the right.
9179    //  - Bottom line — link icon + final-URL text, full width of the
9180    //    column so long URLs can expand without competing with buttons.
9181    urlHintCol: {
9182      flexDirection: 'column',
9183      paddingTop: 4, paddingHorizontal: 2,
9184    },
9185    urlHintStatusLine: {
9186      flexDirection: 'row',
9187      alignItems: 'center',
9188      justifyContent: 'space-between',
9189      gap: 6,
9190    },
9191    urlHintActions: {
9192      flexDirection: 'row',
9193      alignItems: 'center',
9194      gap: 6,
9195    },
9196    urlHintText: { flex: 1, fontSize: 11, color: Palette.textMuted, lineHeight: 15 },
9197    // Single line inside the status panel — pairs an icon with its
9198    // text so they share a row and stay vertically aligned via the
9199    // row's `alignItems: 'center'`.
9200    urlHintLine: {
9201      flexDirection: 'row',
9202      alignItems: 'center',
9203      gap: 6,
9204    },
9205    // Press feedback for the link line — fires when detailPress is
9206    // defined, so the tap reads as actually doing something.
9207    urlHintLinePressed: { opacity: 0.55 },
9208    // Hidden state for the link line — content stays in the layout
9209    // (preserving the panel's height) but is fully transparent. Pair
9210    // with `disabled` on the Pressable so taps don't fire.
9211    urlHintLineReserved: { opacity: 0 },
9212    urlHintProxiedLabel: {
9213      fontSize: 8,
9214      color: Palette.highlight,
9215      fontWeight: '700',
9216      letterSpacing: 0.8,
9217      textTransform: 'uppercase',
9218      marginBottom: 1,
9219    },
9220    // Small reload glyph anchored to the right of the URL status row.
9221    // Status-row "Share Final" pill — matches the urlActionPill
9222    // geometry used by Share / Paste / Clear up in the SOURCE row
9223    // (gap 3, padding 2/7, pill radius, muted border, bgElevated).
9224    urlHintShareBtn: {
9225      flexDirection: 'row',
9226      alignItems: 'center',
9227      gap: 3,
9228      paddingVertical: 2,
9229      paddingHorizontal: 7,
9230      borderRadius: 999,
9231      borderWidth: 1,
9232      borderColor: Palette.border,
9233      backgroundColor: Palette.bgElevated,
9234      alignSelf: 'center',
9235    },
9236    urlHintShareBtnText: {
9237      fontSize: 11,
9238      color: Palette.accentBright,
9239      fontWeight: '700',
9240    },
9241    // Disabled-state for the status-row Share button — keeps the
9242    // glyph in place so the row geometry doesn't reflow, but reads
9243    // as inert.
9244    urlHintShareDisabled: { opacity: 0.35 },
9245    // Shown only when the matched proxy destination has randomise on +
9246    // "Proxied" + cycle pill share one Pressable so tapping either
9247    // text or icon cycles. Tight inline grouping — both members read
9248    // as one affordance, with the trailing " · Stripped …" piece
9249    // sitting visibly outside.
9250    urlHintProxiedGroup: {
9251      flexDirection: 'row',
9252      alignItems: 'center',
9253      gap: 6,
9254    },
9255    // The actual launchable URL — slightly brighter than the muted hint
9256    // copy so it reads as "the thing Warden will send" while still
9257    // sitting in the same panel chrome.
9258    urlHintLaunch: {
9259      color: Palette.text,
9260      fontFamily: Fonts?.mono,
9261    },
9262    // Painted over the host portion of the displayed final URL when a
9263    // proxy rewrite ran — makes the rewritten host pop visually so the
9264    // user can see exactly what part of the link Warden changed.
9265    urlHintProxiedHost: {
9266      color: '#d6b75a',
9267      fontWeight: '700',
9268    },
9269    // Quiet placeholder shown next to the link icon while no
9270    // parseable URL has landed. Dimmer than textMuted so it reads
9271    // as "absence" rather than "error" — the status caps line above
9272    // already carries the error message.
9273    urlHintPlaceholder: {
9274      color: Palette.textMuted,
9275      opacity: 0.7,
9276      fontSize: 11,
9277    },
9278    // Small annotation when tracking params were removed.
9279    urlHintStripped: {
9280      color: Palette.accentBright,
9281      fontWeight: '600',
9282    },
9283    // Negative marginLeft offsets the first pill's internal padding
9284    // (paddingHorizontal: 7 + 1px border) so the leading shield icon
9285    // visually lines up with the browser-name baseline above, instead
9286    // of sitting indented by the pill chrome.
9287    ratingsRow: { flexDirection: 'row', gap: 6, marginTop: 5, marginLeft: -8 },
9288    ratingPill: {
9289      flexDirection: 'row',
9290      alignItems: 'center',
9291      gap: 3,
9292      paddingHorizontal: 7,
9293      paddingVertical: 2,
9294      borderRadius: 999,
9295      borderWidth: 1,
9296      borderColor: Palette.border,
9297      backgroundColor: Palette.bgElevated,
9298    },
9299    ratingScore: { fontSize: 11, color: Palette.text, fontWeight: '700' },
9300    ratingScale: { fontSize: 9, color: Palette.textMuted, fontWeight: '600' },
9301    chev: { fontSize: 22, color: Palette.textMuted, marginLeft: 6 },
9302  
9303    unknownBox: {
9304      marginTop: 14, backgroundColor: Palette.surfaceDim, borderWidth: 1, borderColor: Palette.border,
9305      borderRadius: 10, paddingVertical: 10, paddingHorizontal: 12,
9306    },
9307    unknownLabel: { fontSize: 9, letterSpacing: 1.5, color: Palette.textMuted, fontWeight: '700', marginBottom: 4 },
9308    unknownHint: { fontSize: 11, color: Palette.textMuted, marginBottom: 6 },
9309    unknownPkg: { fontSize: 11, color: Palette.cream, fontFamily: Fonts?.mono, paddingVertical: 1 },
9310  });
9311