.design/P12_5_TWEAKS_RESEARCH_REVIEW.md
Reviewer: UX-Research. Target:
.design/P12_5_TWEAKS_REDESIGN.mdby ArchitectUX. Date: 2026-05-23. Evidence base: Nielsen Norman Group (NN/g), Material 3 guidance, Apple HIG (Settings + Localization), WCAG 2.2, and direct comparison with Tailscale, Cloudflare WARP, Bitwarden, 1Password, iOS Settings, and Termux. Code references cite the architect's own line numbers in the spec.
Iterate before ship. The IA refactor is directionally right and most per-screen specs are sound, but the Connection redesign has a load-bearing mental-model gap, telemetry is silently missing, and several discoverability/accessibility/i18n risks need answers in writing — not in a comment thread during implementation.
Problem. TweaksViewModel injects TelemetryRepository (confirmed in feature/tweaks/CLAUDE.md → "TweaksViewModel injects: …, TelemetryRepository, …"). The redesign spec contains zero occurrences of the word "telemetry" across all 668 lines. It is not on the hub, not in About, not in Library cleanup. If it exists today, the spec deletes it by omission. If it doesn't yet have UI, this is the redesign moment to add it.
Evidence. Modern privacy-forward apps universally surface telemetry as an explicit user-facing toggle within one tap of Settings root: Bitwarden ("Help us improve" → off by default), Tailscale ("Send usage analytics"), Cloudflare WARP ("Diagnostics"), 1Password ("Help us improve 1Password"). Hiding a telemetry control inside a sub-screen, or worse omitting it, fails GDPR's "easy to find" requirement for consent management (Article 7(3)) and Apple App Store guideline 5.1.2.
Proposed fix. Add a "Privacy" card to the About sub-screen (preferred — keeps "App"/About as the meta home) OR move it into Library cleanup and rename that screen "Privacy & cleanup" (the architect even hinted at this in §7.2 #6: "a quieter alternative is 'Privacy & storage'"). Surface: toggle "Share anonymous usage data" + 1-line body + link "What we collect →" (modal or expandable). Also: any crash-reporter opt-out, if CrashReporter (desktop) has one.
Severity. Critical (legal/compliance + user trust + an injected repository with no UI is a code smell on its own).
Problem. §4.2 #4 admits the master model is presentation-only: "ProxyRepository stores per-scope ProxyConfig already. On open, we read all three; if they're identical, we treat them as 'master.' If they diverge, we mark the divergent ones as 'overridden'." This derivation is invisible to the user.
The bigger issue is in §4.3, Card 1 step 4: "Test … runs OnProxyTest against ProxyScope.DOWNLOAD (canonical scope for testing the master)." The user reads "Test" and thinks "test this proxy." The app actually tests Download scope only. If Discovery has an override and Download doesn't, master-test passes and the user assumes their setup is fine — but Discovery is broken.
Evidence.
Proposed fix.
ProxyRepository, plus a useMaster: Boolean per scope. Don't derive it from equality. (Yes, this is a schema change — that's the cost of the chosen IA. §7.4 lists "ProxyRepository schema" as untouched; that decision should be reversed.)tailscale netcheck does. Roundtrip cost is acceptable — testing is rare.Severity. Critical (load-bearing UX claim + persistence correctness).
Problem. §6 row: proxy_none "None" → "Direct". The architect's reasoning (§4.3 "'Direct' replaces today's ProxyType.NONE. Renamed for clarity — 'None' sounds like an error state.") is half-right and half-wrong.
Evidence.
DIRECT). In the 13 locales the app ships, "Direct" has no shared meaning. In German "Direkt" works; in Chinese (Simplified) the literal "直接" is awkward UI copy — Chrome zh-CN uses "不使用代理服务器" ("do not use proxy server"); in Russian "Прямое соединение" is verbose. In Polish "Bezpośrednio" — same problem.Proposed fix. Three options, ranked:
Pick #1. Also rename proxy_none_description from "Connect directly, no proxy." to "The app connects to the internet without a proxy."
Severity. Critical (13 locales × one mistranslation per locale = compounding bad copy).
Problem. The spec handles empty states on some screens but ignores them on others:
skipped_updates_entry_description is just descriptive; with the new dynamic-subtitle pattern (§2.2 "reflects the current value of that domain"), a zero state needs friendly copy.Radii.row" — but the hub subtitle (§1.1) says "No tokens yet" when empty. The actual HostTokensScreen empty state is unaudited. A user tapping "No tokens yet" deserves an "Add your first token" CTA, not a blank list.Confirmation dialogs are also inconsistent:
GhsConfirmDialog. Good.GhsConfirmDialog. Good.ClearDownloadsDialog. Need to confirm copy is consistent with GhsConfirmDialog vocabulary post-D4 squircle scope.Evidence. NN/g, "10 Usability Heuristics," #5 Error prevention and #9 Help users recognize, diagnose, recover. Material 3 Confirmation Dialog guidance: "Use for destructive actions that can't be undone, or that change scope of effect."
Proposed fix. Add an "Empty / loading / error states" sub-section to every per-screen spec. Standardize the destructive-action pattern across the redesign:
GhsConfirmDialog.Severity. Critical (inconsistency between sibling screens degrades trust + lookahead for translators).
Problem. §2.6 rejects search; §1.2 declines greyed-out cross-platform rows; §7.1 (Discoverability loss) acknowledges "Today a user scrolling Tweaks accidentally discovers 'Custom forges' or 'Hide seen.' In a hub-and-spoke model, those settings are one tap further away. Mitigation: the hub's dynamic subtitles … make the state visible without entering." The mitigation is partial — subtitle visibility helps recognition, not initial discovery, and only for users who already know what to look for.
Evidence.
The architect's three reasons for rejecting search (§2.6) are:
Proposed fix. Either:
WonkySquircleShape.Search shape, filters rows + their subtitle text. Cost: ~20 lines of Compose. Removes the entire discoverability complaint.githubstore://tweaks/connection, githubstore://tweaks/translation, githubstore://tweaks/updates. These can be invoked from notifications ("Battery optimization is blocking updates →"), from in-app banners (the existing D3 themes-refreshed banner), and from external "open settings" deep links.Even better: ship both. Search is cheap, deep links are infrastructure.
Severity. Critical (user-explicit "rethought" goal vs new friction; user is a power user who navigates Tweaks weekly, not yearly).
Problem. The user's brief explicitly asks this question. §3.8 puts under one roof:
These have nothing in common except "things the app remembers." The clipboard toggle in particular has zero to do with "cleanup."
Evidence.
Proposed fix. Two options:
titleSmall sub-headers (the screen-internal convention from §3 general). Less disruptive, slightly muddier.Prefer #1. The user's "rethink each category" directive supports it.
Severity. Major (named in user's original brief).
Problem. §4.3 Card 2 #3: "Each row has a trailing toggle 'Use main connection' (default ON). When toggled OFF, the row expands … into a mini-editor." §7.2 #3 already flags this: "Could also be 'Inherit from main' — more technical, possibly clearer to power users."
The user audience is power users. "Use main connection" reads ambiguously — it sounds like enabling a connection, not inheriting a setting. The toggle is also semantically inverted from the visual: ON = no editor visible (you can't change anything). Users will toggle it to "see the controls" and then panic when they realize they just unhooked the override.
Evidence.
Proposed fix. Replace the toggle with a 2-way segment per scope row:
[ Use main ] [ Custom… ]
"Custom…" reveals the mini-editor. "Use main" hides it. This is what 1Password does for per-vault settings ("Inherit / Custom"), and Bitwarden for organization policies ("Use default / Override").
Bonus: a segment makes the "override is on" state visually obvious without subtitle parsing.
Severity. Major (every power user hits this).
Problem. §4.3 mode segment offers Direct / System / HTTP / SOCKS. The user community asking for proxy support (China + privacy-conscious EU + Tor) will request:
ProxyConfig.Socks doesn't expose version — fine if SOCKS5 only, but worth confirming. Tor's local proxy is SOCKS5; some corporate proxies are SOCKS4a.ProxyConfig.Http is ambiguous in name.socks5://user:[email protected]:1080). Tailscale + warp-cli + curl all accept this format. Five fields is a lot of typing; one paste field is mass-market UX.Evidence.
warp-cli set-custom-proxy with full URL.Proposed fix.
ProxyConfig.Socks is SOCKS5-only (spec it explicitly: rename mode pill "SOCKS5" rather than "SOCKS"). If SOCKS4 is needed, add a sub-toggle.Proxy.Type.HTTP which serves both). If yes, rename the pill "HTTP/HTTPS" or just leave "HTTP" with a tooltip.scheme://user:pass@host:port and populates the form. Don't replace the form; supplement it.Severity. Major (will generate GitHub issues within a week of release if not addressed).
outlineVariant.copy(alpha = 0.55f) will fail WCAG AA on Cream (amber light) paletteProblem. §2.2: "border = BorderStroke(1.dp, outlineVariant.copy(alpha = 0.55f))". This is the universal border for the entire redesign. Material 3's outlineVariant is already low-contrast (~3:1 against surfaceContainerLow), and multiplying by 0.55 alpha makes it ~1.7:1 against light Cream surfaces.
Evidence.
outlineVariant is designed for low-emphasis dividers, not standalone container borders. When used as a container border (your case), Material recommends outline (which is outlineVariant boosted ~2x).D3. Cream's surfaceContainerLow is high-luminance off-white; the border will be nearly invisible.Proposed fix. Either:
outlineVariant at full opacity.outline at 0.55 alpha (lands at roughly the same visual weight on dark themes but stays above 3:1 on Cream light).TweaksEntryRow and confirm border visibility before committing.Run option #2, then audit.
Severity. Major (accessibility compliance + Cream light is the maintainer's recently-changed lead-theme; visually buggy there is highly visible).
Problem. §2.2 hub row: "padding(horizontal = 16.dp, vertical = 14.dp)" + icon tile 40.dp + two-line text. Total row height depends on font metrics; with Geist titleMedium (~22sp line height) + 2dp gap + bodySmall (~16sp) + 28dp = roughly 68dp tall. ✔ exceeds 48dp.
But: §3.4 Custom forges row has a trailing IconButton for delete ("Trailing: Icons.Outlined.DeleteOutline IconButton → confirmation"). A 24dp icon inside the row needs 48dp tap target per Material guidance and 44pt per Apple HIG. Spec doesn't say. Also: an IconButton inside a clickable row creates a nested-click trap — tapping near the icon may register the row click first (Compose ripple bubbles to outer clickable unless the inner is wrapped in onClick = {} with interactionSource).
Evidence.
Modifier.clickable swallows child clicks if not explicitly excluded via consumeWindowInsets or empty inner click handler (this has been a recurring bug — see Compose 1.6 release notes on clickable consumption).Proposed fix.
IconButton as 48dp min with internal 24dp icon and Modifier.clip(Radii.chip) for ripple.Modifier.clickable to not be the only click handler — the row click navigates, but the delete button stops propagation.semantics {
role = Role.Button
contentDescription = "$title. $subtitle. Double-tap to open."
}
contentDescription = null (decorative). Status pills (Install method "Needs permission"): exposed as a liveRegion = Polite so TalkBack announces re-evaluation when permissions change.Severity. Major (accessibility is global app concern, not per-feature).
Problem. §2.2: "Subtitle: bodySmall, onSurfaceVariant, max 1 line, ellipsize." §1.1 examples: "DeepL · auto on", "Auto · every 6h", "Nord · Dark". These are English-shape sentences.
Text handles BOM but the dot character should be locale-aware. Also no spaces between words is the norm in CJK, so "DeepL · 自动开启" works but feels foreign.LocaleTextDirection.Content so dot-separated tokens lay out right.custom_forges_count_label) — this requires Android plural resources (plurals.xml) per locale; Compose Multiplatform Resources supports plurals (pluralStringResource in org.jetbrains.compose.resources since 1.6). Russian has 3 plural forms, Polish has 3, Arabic has 6. Naive "%d hosts" breaks all of them.Evidence.
pluralStringResource is the supported API; today's code uses stringResource(..., count) which is not plural-aware.Proposed fix.
pluralStringResource for any subtitle / label with a count, and add plural XML for every locale that doesn't have it. Audit: %d tokens, %d hosts, %d apps (skipped updates count).LocalConfiguration.current.locales → CJK locales use 「· 」(with width adjustment) or " / "; non-CJK uses " · ".CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) only for the chevron icon — text content respects user direction.Severity. Major (13 locales is a load-bearing project property).
Problem. §1.3 / §3.10: Send feedback moves into About sub-screen. The hub no longer surfaces feedback. Today, a user encountering a bug can reach Feedback in: Profile → Tweaks → scroll to about → Feedback button. New flow: Profile → Tweaks → About row → Feedback row → sheet. One extra tap at the worst possible moment — the user is already frustrated.
Evidence.
Proposed fix. Three patterns to consider:
CrashReporter's presence). Best-case UX, more engineering.Ship #1. It's the cheapest and matches industry norm.
Severity. Major (named in user's brief; emotional moment).
Problem. §1.2: "Hub omits the Install method row entirely on desktop. No greyed-out placeholder." §3.6 Desktop note: "if reached via deep link on desktop, show a centered empty state — 'Install method is Android-only'". Hiding entirely is consistent with iOS HIG ("don't show what doesn't apply"). But:
Evidence.
Proposed fix. Show the row on desktop with a subtitle badge "Android only" (use tertiaryContainer pill), clicking shows the §3.6 empty state with explanation. Same for any future iOS-only or Linux-only rows. The visual cost (one row) is dominated by the discoverability gain.
(Alternative: leave architect's decision intact, but require a one-line entry under About on desktop: "On Android: install method, silent updates, attribution.")
Severity. Major (user is dual-platform power user).
Problem. §1.1 block "Network & data" contains: Connection, Sources, Translation, Access tokens.
Evidence.
section_network "Network" → "Network & data" (hub block header) — calling this a "data" block is a stretch when 2 of 4 children are arguably not about data movement.Proposed fix. Re-block:
That's 7 blocks for 12 rows — heavier than the current 4 blocks for 10 rows, but each block is internally coherent. Alternative compromise: keep 4 blocks but rename "Network & data" → "Connectivity" and move Translation out (e.g. into Look & feel or its own micro-block).
Severity. Major (the user's brief explicitly asked about this).
Problem. §3.2: "Validation / events: selecting a language fires OnAppLanguageSelected(tag) → existing OnAppLanguageChangeRequiresRestart snackbar with 'Restart now' action. Unchanged."
But the new flow is: hub → tap "Language" row → arrive on TweaksLanguageScreen → tap a language → snackbar appears on … which screen? If the snackbar appears on TweaksLanguageScreen and the user taps "Restart now," the app restarts and lands on Home (existing behavior) — user loses position. If the user dismisses the snackbar and navigates back to the hub, the snackbar host is gone — but the language is still set, and the app hasn't restarted. Are language and UI now in inconsistent state until the next restart?
Evidence.
TweaksRoot.kt has one SnackbarHostState for the whole screen; the snackbar persists across user actions until dismissed.SnackbarHostState") means the language snackbar belongs to TweaksLanguageScreen only. Navigating back drops it.Proposed fix.
TweaksLanguageScreen that says "Language changed. Restart the app to see it everywhere. [Restart now] [Later]". Reuse existing Banner if any, otherwise a Radii.row outlined Surface with tertiaryContainer tint.TweaksRepository so navigating back to the hub still shows a top-level banner: "Restart pending — some screens may still show the old language."Severity. Major (today's behavior is acceptable monolithic; new IA breaks it implicitly).
0.985× on desktop, 0.97× on Android (per D10's mention of MediumBouncy).SupportedTranslationLanguages.all." Otherwise risk of two diverging language pickers maintained separately.<xliff:g> placeholders for the three product names so translators don't relocalize "Codeberg".Geist Mono labelLarge — confirm mono font weight reads on the smaller subtitle line below; mono fonts often need +1 weight bump to match proportional siblings.HostTokenInterceptor). It should — testing without the token won't catch auth-broken proxies for private GH Enterprise + PAT users.installer_type_default → "System installer (Default)" — the parenthetical adds noise. Just "System installer" reads cleaner. The word "Default" was always a non-noun in this UI.Radii.chip, background colorScheme.surfaceContainerHigh, padded 8.dp, tinted onSurfaceVariant" — every row has its own colored tile. The architect listed icons by Material name only; no per-row tile tint. Material 3 settings convention is one tile color (neutral) or one tint per block (not per row). Per-row colored tiles add visual noise; the spec already doesn't do this, but the user's brief asked the question — confirm in the spec that tiles are uniformly tinted (not per-icon-color). One line in §2.2.resolvedDark, not the raw isDarkTheme value" — call out that this is true for the new Mode segment too. The toggle visibility computation depends on the segment selection + system theme. State machine deserves one diagram in spec.pl-PL) is great UX. But for "Follow system" the subtitle should show the resolved tag in parens, e.g. "Follow system (en-US)" so the user knows what they're getting.feature/tweaks/presentation/components/sections/Network.kt:161-171) is the worst pattern in the entire feature. The user's complaint here is fully justified and the architect heard it..uppercase()). Universally correct call; ALLCAPS feels shouty in 2026 UI. (§2.3)RoundedCornerShape(32.dp) cards in favor of Radii.row consistency. The current code has three competing shape vocabularies; collapsing to one is overdue. (§5.3)ProxyRepository schema needs reversal per C2.)TelemetryRepository is injected but there's no UI surface I could find.) If yes, where; if no, do we ship the opt-out in this PR or block on backend? (C1)ProxyRepository schema bump acceptable in this phase? Adds one field per scope (useMaster: Boolean) + a fourth master record. (C2)[needs translation] markers, queue with maintainer translators, or block redesign on 13-locale parity? (M6)End of review.