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