.design/P12_5_TWEAKS_REDESIGN.md
Hub-and-spoke IA for the Tweaks feature. Replaces the single-scroll mega-list in
feature/tweaks/presentation/TweaksRoot.ktwith a category hub + dedicated drill-in screens. Reuses the existing design-system vocabulary (Radii.row,WonkySquircleShape.*, outlined Surface row,FloatingPill,SectionText/Squiggle,ToggleSettingCard,GhsBottomSheet,GhsConfirmDialog,GhsDropdownMenu,PlatformGlyph). No new fonts. No square corners. No KDoc.Author: ArchitectUX. Status: V2, post UX-Research critique + product-owner decisions. Supersedes V1 in full.
Citations point to .design/P12_5_TWEAKS_RESEARCH_REVIEW.md (review) and .design/P12_5_ABOUT_PLACEMENT_RESEARCH.md (placement).
| # | Area | V1 said | V2 says | Driver |
|---|---|---|---|---|
| 1 | Telemetry UI | absent | first-class opt-out, lives in new Privacy sub-screen | review C1 |
| 2 | Library cleanup | one screen mixing cache + clipboard + history + telemetry-shaped concerns | split into Storage (APK cache) + Privacy (telemetry, clipboard, hide-seen, viewed history) | review M1 |
| 3 | About leaf | "About" sub-screen with feedback nested inside | App info sub-screen (About + Licenses + Privacy policy + version) plus dedicated Send feedback hub row at bottom; desktop also gains MenuBar | placement Option E + review M7 |
| 4 | Master proxy | derived from per-scope equality at load | persisted as its own record + useMaster: Boolean per scope; one-time migration | review C2 |
| 5 | "Direct" rename | proxy_none → "Direct" | proxy_none → "No proxy" (mode pill + body copy updated everywhere) | review C3 |
| 6 | Search in hub | rejected | shipped, WonkySquircleShape.Search field under topbar, filters by title + subtitle; deep-link routes spec'd as a follow-up | review C5 |
| 7 | Empty / loading / error | partial per screen | every sub-screen carries a standardized states sub-section; reversible → snackbar+Undo, destructive → GhsConfirmDialog, scope-changing → snackbar confirming scope | review C4 |
| 8 | Override toggle | "Use main connection" switch | 2-segment chooser per scope: [ Use main ] [ Custom… ] | review M2 |
| 9 | Proxy modes | Direct / System / HTTP / SOCKS | No proxy / System / HTTP/HTTPS / SOCKS5 + "Paste full URL" affordance; PAC explicit v1 non-goal | review M3 |
| 10 | Border contrast | outlineVariant.copy(alpha = 0.55f) | outline at full opacity (with documented per-palette contrast audit gate before merge) | review M4 |
| 11 | Tap targets / a11y | unspec'd for nested icon buttons | every interactive 48dp min; explicit nested-click pattern; row semantics + status pill liveRegion; chevron contentDescription = null | review M5 |
| 12 | i18n | English-only rewrite | mandate pluralStringResource for counted labels; 32-char subtitle cap with rightmost-token drop on overflow; English-first ship policy + translator CSV handoff | review M6 |
| 13 | Cross-platform rows | hidden on inapplicable platform | shown with tertiaryContainer "Android only" / "Desktop only" subtitle badge; clicking routes to centered empty state | review M8 |
| 14 | Hub blocking | 4 blocks for 10 rows | 5 blocks for 11 rows (Look & feel, Connectivity, Installs & updates, Privacy & data, App); Translation moves into Connectivity; Access tokens into Privacy & data | review M9 (trimmed) |
| 15 | Restart UX | snackbar local to Language screen | persistent banner sourced from TweaksRepository.needsRestartReasons; shows on hub + every sub-screen until restart | review M10 |
| 16 | Nits | various | applied N1–N12 (see §6 + per-screen specs) | review nits |
| 17 | Connection mode label | "Direct" | "No proxy" | review C3 |
| 18 | Update interval | 3h / 6h / 12h / 24h | "Every 6 hours / Every 12 hours / Daily / Manual only" (drops 3h per rate-limit risk) | review N3 |
| 19 | Installer rename | "System installer (Default)" | "System installer" | review N7 |
| 20 | Licenses row | placeholder, hideable | shipped, sourced from gradle-license-plugin JSON; row in App info | review N8 |
| 21 | Icon tile tinting | unstated | explicitly uniform surfaceContainerHigh tile with onSurfaceVariant icon tint across all rows | review N9 |
| 22 | "Follow system" subtitle | static "Follow system" | "Follow system · en-US" (resolved tag in parens) | review N12 |
V1 strengths preserved: outlined Radii.row vocabulary across all sub-screens, sentence-case section headers (no more .uppercase()), dynamic-state hub subtitles, §3.5 Translation provider radio redesign, empty-state framing for custom forges, §7.4 explicit "did not change" discipline (now §7.6).
The hub is grouped into 5 visual blocks, 11 entry rows total. Each block has its own SectionText + Squiggle underline, then a stack of entry rows. No top-level "Settings" section header — the topbar already says "Tweaks".
| Block | Order | Entry row | Icon (Material outlined) | Subtitle (dynamic state) | Drill-in | Platforms |
|---|---|---|---|---|---|---|
| Look & feel | 1 | Appearance | Palette | Palette + mode, e.g. "Nord · Dark" | TweaksAppearanceScreen | All |
| 2 | Language | Translate | Language name, e.g. "English (US)" or "Follow system · en-US" | TweaksLanguageScreen | All | |
| Connectivity | 3 | Connection | Wifi | "No proxy" / "HTTP 127.0.0.1:1080" / "System proxy" / "127.0.0.1:1080 · 1 override" | TweaksConnectionScreen | All |
| 4 | Sources | Hub | "GitHub + N forges" | TweaksSourcesScreen | All | |
| 5 | Translation | GTranslate | Provider name + auto state, e.g. "DeepL · auto on" | TweaksTranslationScreen | All | |
| Installs & updates | 6 | Install method | InstallMobile | Installer + Ready / Needs permission badge | TweaksInstallScreen | All (Android-only behavior; desktop shows badge) |
| 7 | Update behavior | Update | "Every 6 hours" / "Manual only" / "Check failed" badge | TweaksUpdatesScreen | All | |
| Privacy & data | 8 | Storage | Inventory2 | Live cache size, e.g. "Downloads: 124 MB" | TweaksStorageScreen | All |
| 9 | Privacy | PrivacyTip | Compact state, e.g. "Telemetry off · clipboard on" | TweaksPrivacyScreen | All | |
| 10 | Access tokens | VpnKey | "N tokens" (plural-aware) / "No tokens yet" | HostTokensScreen (existing) | All | |
| App | 11 | App info | Info | App version, e.g. "1.8.3" | TweaksAppInfoScreen | All |
| 12 | Send feedback | Feedback | "We read every report." | (opens FeedbackBottomSheet directly) | All |
Notes on the IA:
tertiaryContainer pill). Clicking routes to a centered empty state.MenuBar (see §8).FeedbackBottomSheet on both.| Existing thing | New home |
|---|---|
MirrorPickerScreen | Reached from Sources (existing nav route preserved) |
HostTokensScreen | Reached from Access tokens hub row (existing nav route preserved) |
SkippedUpdatesScreen | Reached from Update behavior |
HiddenRepositoriesScreen | Reached from Update behavior |
WhatsNewHistoryScreen | Reached from App info |
FeedbackBottomSheet | Opens directly from the Send feedback hub row (no intermediate screen) |
CustomForgesDialog | Replaced by CustomForgesSheet opened from Sources |
ClearDownloadsDialog | Opened from Storage |
| Clipboard / hide-seen / viewed-history controls | Moved into Privacy sub-screen |
| Telemetry opt-out (new UI) | Privacy sub-screen |
| About / Licenses / Privacy policy link | Folded into App info sub-screen |
TweaksScreen (the new root)Pattern: plain large title, no FloatingPill.
Rationale: FloatingPill is the "in-content overlay" pattern used in Search + Auth where the topbar floats over scrolling hero content. Tweaks has no hero. Tweaks is a deep settings hub — it wants a steady, anchored title.
Spec:
LargeTopAppBar with collapsing behavior (TopAppBarDefaults.exitUntilCollapsedScrollBehavior).Res.string.tweaks_title).colorScheme.background.TweaksEntryRowThe hub is a list of 11 identical rows grouped into 5 sub-sections. The row is the shared primitive, lives at feature/tweaks/presentation/components/TweaksEntryRow.kt.
Shape & color:
Surface(shape = Radii.row, color = colorScheme.surfaceContainerLow, border = BorderStroke(1.dp, colorScheme.outline)).colorScheme.outline at full opacity. Drops V1's outlineVariant.copy(alpha = 0.55f) per review M4 (failed WCAG 1.4.11 on Cream light). Implementer must screenshot Nord/Cream/Forest/Plum × Light/Dark/AMOLED matrix and confirm border passes 3:1 contrast before merge. Fallback if outline reads too heavy on dark: outline.copy(alpha = 0.55f) but only after measured contrast > 3:1 on Cream light.Row, Modifier.fillMaxWidth().clickable { onClick() }.padding(horizontal = 16.dp, vertical = 14.dp).Visual zones, left → right:
Radii.chip, background uniformly colorScheme.surfaceContainerHigh, padded 8.dp, icon tinted onSurfaceVariant. Never per-row colored. (review N9)Modifier.weight(1f)):
titleMedium, onSurface, FontWeight.SemiBold.bodySmall, onSurfaceVariant, max 1 line, ellipsize end. Dynamic — reflects current domain state.tertiaryContainer background, Radii.chip shape, labelSmall, FontWeight.Medium. Used by Install method ("Ready" / "Needs permission" / "Android only"), Update behavior ("Check failed"). When present, the row's Row is semantics { liveRegion = LiveRegionMode.Polite } so TalkBack announces re-evaluation when state changes.Icons.AutoMirrored.Filled.ChevronRight, 24.dp, tinted onSurfaceVariant, contentDescription = null (decorative).Tap targets & nested clicks (review M5):
IconButton (e.g. delete on Custom forges row) wraps in Modifier.size(48.dp).clip(Radii.chip).Modifier.clickable and inner IconButton.onClick correctly dispatch to the nearest handler — no manual consumeWindowInsets needed. Implementer must verify in a smoke test (tap the inner icon, confirm only its handler fires).A11y semantics (review M5):
Modifier.semantics {
role = Role.Button
contentDescription = "$title. $subtitle. Double-tap to open."
}
Chevron contentDescription = null. Status pill participates via liveRegion = Polite. Override segment (in Connection sub-screen) announces "Discovery override on, custom proxy settings for Discovery" when expanding.
Press feedback: standard ripple inside the squircle border. Spring scale-on-press: 0.97× on Android, 0.985× on desktop (review N1), MediumBouncy per D10.
Component sketch:
@Composable
fun TweaksEntryRow(
title: String,
icon: ImageVector,
subtitle: String? = null,
badge: (@Composable () -> Unit)? = null,
onClick: () -> Unit,
modifier: Modifier = Modifier,
)
TweaksRepository exposes needsRestartReasons: StateFlow<Set<RestartReason>> and clearRestartReasons(). Reasons enum: LANGUAGE, THEME_MIGRATION, TELEMETRY_TOGGLE.
Banner placement:
LazyColumn, above the search field. Sticky during collapsing topbar scroll.LazyColumn, above the first card.Banner visual:
Surface(shape = Radii.row, color = colorScheme.tertiaryContainer, border = BorderStroke(1.dp, colorScheme.outline)).WonkySquircleShape.CtaPrimary, FilledTonalButton) + "Later" (text button). "Later" dismisses for the current session only — banner returns next launch until restart.Banner disappears only when needsRestartReasons is empty (i.e. app process restarted).
Reuses SectionHeader from feature/tweaks/presentation/components/SectionText.kt (titleLarge + Squiggle). Section labels (sentence-case, never uppercase):
A single inline filter field, lives between the restart banner (if visible) and the first section header.
OutlinedTextField, shape WonkySquircleShape.Search, leading icon Icons.Default.Search, trailing clear icon (when non-empty), placeholder "Search settings" (sentence-case, no period).remember { mutableStateOf("") }. Filters the static row list in TweaksScreen by title.contains(query, ignoreCase = true) || subtitle?.contains(query, ignoreCase = true) == true.Radii.row), Icons.Outlined.SearchOff icon tile + "No settings match '<query>'."Deep-link routes (githubstore://tweaks/<category>) are spec'd here as a follow-up ticket (P12.5.1, post-launch). Not in v1 scope. Route names when implemented: appearance, language, connection, sources, translation, install-method, updates, storage, privacy, access-tokens, app-info, send-feedback.
LazyColumn(contentPadding = 16.dp horizontal, top = 8.dp, bottom = bottomNavHeight + 32.dp)
restart banner (if reasons non-empty)
Spacer(12.dp)
search field
Spacer(16.dp)
section header 1
Spacer(8.dp)
entry rows (separated by Spacer(8.dp))
Spacer(24.dp)
section header 2
...
TweaksState. The only field that can lag is App info's versionName; placeholder "—" until populated.TweaksState. If a subtitle's underlying flow errors (e.g. cache size read fails), the row still renders with a graceful fallback subtitle ("Tap to manage").Conventions shared by every sub-screen:
MediumTopAppBar + back arrow + title. No subtitle line in the bar.Scaffold(containerColor = colorScheme.background) + LazyColumn, contentPadding = 16.dp horizontal, 8.dp top, bottomNavHeight + 32.dp bottom.needsRestartReasons non-empty.Radii.row outlined Surface (surfaceContainerLow + full-opacity outline border per M4).titleSmall + FontWeight.SemiBold, onSurface, padded start = 4.dp, top = 16.dp, bottom = 8.dp. No Squiggle inside sub-screens.SnackbarHostState, subscribed to TweaksViewModel.events filtered to events applicable to it.GhsConfirmDialog.TweaksAppearanceScreenTitle: "Appearance"
Layout (top → bottom):
Radii.row.
ModePillSegment (§5.1).FlowRow): 4 PaletteSwatches — Nord, Cream, Forest, Plum.AnimatedVisibility(resolvedDark) AMOLED inline toggle.Radii.row, inner Column.
Compact / Wide / Extra wide).Empty / loading / error: no async loads; state is synchronous from TweaksState.appearance. Palette change → crossfade (D10) + snackbar "Theme applied." Theme migrations that need restart (rare, only on major palette schema change) push RestartReason.THEME_MIGRATION into needsRestartReasons.
Gotchas: AMOLED visibility binds to resolvedDark (System + dark OS still shows). When mode segment switches to Light explicitly, AMOLED collapses with AnimatedVisibility(false). State machine: AMOLED visible ⇔ (mode == Dark) || (mode == FollowSystem && systemIsDark). (review N11)
TweaksLanguageScreenTitle: "Language"
Layout:
Radii.row):
OutlinedTextField, WonkySquircleShape.Search shape, leading search icon, placeholder "Search languages".Radii.row outlined Surface row:
pl-PL) in Geist Mono labelSmall.Icons.Default.Check (24.dp, colorScheme.primary) when selected.Icons.Outlined.PhoneAndroid (Android) / Icons.Outlined.Computer (desktop). Subtitle includes the resolved tag, e.g. "Follow system · en-US" (review N12).Validation / events: selecting a language fires OnAppLanguageSelected(tag) → repository sets language and pushes RestartReason.LANGUAGE into needsRestartReasons. The §2.3 restart banner takes over; no snackbar from this screen.
Empty / loading / error:
TweaksConnectionScreen (proxy redesign)This screen is the centerpiece. Full deep-dive in §4.
TweaksSourcesScreenTitle: "Sources"
Layout:
Radii.row):
Radii.row) → MirrorPickerScreen.
customForgeHosts.isEmpty(): empty-state outlined row, title "Add a Forgejo or Gitea host", subtitle "We already know about codeberg.org and gitea.com. Add others here.", trailing Icons.Default.Add. Opens CustomForgesSheet.Geist Mono labelLarge, +1 weight bump for visual parity with proportional text (review N5).Icons.Outlined.DeleteOutline inside a 48dp IconButton.size(48.dp).clip(Radii.chip). On click → GhsConfirmDialog ("Remove git.disroot.org? Repos hosted there will stop appearing in search.").Radii.row, Icons.Default.Add icon tile, title "Add another host". Opens CustomForgesSheet.CustomForgesSheet (new, replaces CustomForgesDialog):
GhsBottomSheet with WonkySquircleShape.Sheet.OutlinedTextField "Hostname", placeholder "code.example.org", shape WonkySquircleShape.Search.customForgeError).WonkySquircleShape.CtaPrimary, disabled until non-blank + passes validation.Empty / loading / error:
supportingText on the field; failures during validation surface as a snackbar on the Sources screen after the sheet dismisses, e.g. "Couldn't reach code.example.org."GhsConfirmDialog confirms.TweaksTranslationScreenTitle: "Translation"
Layout:
Radii.row):
Radii.chip Surface each. Radio button + icon tile + title + 1-line description.Radii.row) — only when provider needs credentials (i.e. not Google).
OutlinedTextFields with Radii.chip shape.WonkySquircleShape.CtaPrimary FilledTonalButton, full-width, disabled until form valid.Radii.row):
SupportedTranslationLanguages.all (review N2 — same picker, not a parallel one).Empty / loading / error:
onSurfaceVariant, no destructive coloring).TweaksInstallScreenTitle: "Install method"
Platform visibility: hub row shows on both platforms; on desktop the row carries a "Desktop only" → actually "Android only" badge subtitle (review M8). Clicking on desktop routes to a centered empty state (see below). All other content is Android-only.
Layout (Android):
Radii.row):
Radii.row):
colorScheme.primary border on the row + left-edge 4.dp wide primary-tinted bar.WonkySquircleShape.CtaPrimary.Radii.row) — only when silent install ready:
Radii.row) — only when silent install ready:
Desktop empty state:
Icons.Outlined.Computer (48dp) + title "Install method is Android-only" + body "Silent installers and installer attribution are Android features. Desktop installs go through the OS package manager." + back CTA WonkySquircleShape.CtaAlt "Back to Tweaks".Empty / loading / error:
InstallerStatusProvider returns. Badge has liveRegion = Polite.supportingText.TweaksUpdatesScreenTitle: "Update behavior"
Layout:
state.showBatteryOptimizationCard): outlined Radii.row tertiaryContainer tinted, Open / Dismiss buttons.Radii.row):
SkippedUpdatesScreen. Subtitle: plural-aware "%d app" / "%d apps" or "Nothing skipped" when 0.HiddenRepositoriesScreen. Subtitle: plural-aware count or "No hidden repos."Desktop: auto-update card simplifies to a single toggle "Check on launch" (no interval picker, no WorkManager). Skipped + Hidden drill rows still apply. What's-new history moves to App info (V1 had it here; V2 moves it because it's reference content about the app, not update behavior).
Empty / loading / error:
tertiaryContainer), in-screen banner "Last check failed at 14:02 — [Retry]."pluralStringResource.TweaksStorageScreen (new, split from V1's Library cleanup)Title: "Storage"
Scope: downloaded APK cache only. Everything else (clipboard, hide-seen, viewed history, telemetry) moves to §3.9 Privacy per review M1.
Layout:
Radii.row):
Icons.Outlined.Inventory2.Geist Mono labelMedium: "Using: 124 MB" / "Using: 0 B" when empty.WonkySquircleShape.CtaAlt FilledTonalButton "Clear", errorContainer tinted. Disabled when size = 0 B. Opens ClearDownloadsDialog (existing).Empty / loading / error:
disabledContainerColor per Material 3 disabled-tonal pattern. No empty-state card needed — "0 B" is itself the empty state.TweaksViewModel.OnRefreshCacheSize initialization period).TweaksPrivacyScreen (new — review C1 + M1)Title: "Privacy"
Scope (per product-owner decisions): telemetry opt-out, clipboard detection, hide-seen, viewed-history clear.
This screen is not the home of the legal "Privacy policy" link — that lives in §3.11 App info per placement Option E. The naming overlap is intentional: this sub-screen surfaces in-app behavior toggles; App info surfaces the legal document.
Layout:
Radii.row, inner Column):
Icons.Default.ExpandMore rotates 180°). Expanded body is bulleted plain text — app version, OS + platform, feature counts (no repo names, no tokens, no identifiers). Compose AnimatedVisibility.RestartReason.TELEMETRY_TOGGLE into needsRestartReasons so the banner appears (telemetry init runs at app start).Radii.row):
Radii.row, inner Column):
GhsConfirmDialog ("Clear all viewed history? This won't unstar or unfavorite anything."). On confirm, snackbar with Undo for 5s window, then commits irreversibly. (Treats this as reversible-with-Undo rather than purely irreversible — viewed history is non-critical data.)Empty / loading / error:
Hub subtitle compact state (for §1.1 row 9): pluralStringResource-driven, capped at 32 chars per review M6. Examples:
HostTokensScreen (existing)No visual changes to the screen contents. Audit pending — confirm HostTokensScreen rows use Radii.row outlined Surface + new full-opacity outline border (review M4). If they don't, refactor as part of P12.5.
Hub subtitle: plural-aware "%d token" / "%d tokens" (review M6), or "No tokens yet" when empty.
TweaksAppInfoScreen (new — replaces V1 §3.10 About)Title: "App info"
Scope (per placement Option E): about + licenses + privacy policy link + version metadata. Does not include feedback (separate hub row §3.12). Does not include in-app privacy toggles (those are §3.9 Privacy).
Layout:
Radii.row, single hero-style card):
Icons.Outlined.Store).titleLarge).versionName in Geist Mono labelLarge. Long-press copies to clipboard with snackbar "Version copied."Radii.row drill-in row):
Icons.Outlined.NewReleases, subtitle "Past release notes." Opens WhatsNewHistoryScreen.Icons.Outlined.Code, subtitle "Libraries used in the app." Opens new LicensesScreen fed by gradle-license-plugin JSON (ship as part of P12.5, not a placeholder per review N8). If gradle-license-plugin isn't configured yet, add it in this phase.Icons.Outlined.Description (or PrivacyTip if the §3.9 row uses Shield), subtitle "View on github-store.org." Opens github-store.org/privacy in external browser via LocalUriHandler. Distinct from §3.9 Privacy by icon + subtitle copy.PlatformGlyph GitHub variant; fallback Icons.Outlined.Code), subtitle "View this app's source." Opens the repo URL.Empty / loading / error:
Distinction from §3.9 Privacy: §3.9 = in-app behavior toggles ("what the app does with your data"). §3.11 = static reference content ("what the app is, what its docs say"). They share neither title nor icon set. The hub orders them in different blocks (Privacy & data vs App) so users encounter them with different intent.
Per product-owner decision + placement Option E. Single feedback path (not duplicated inside App info per researcher's open question).
FeedbackBottomSheet directly. No intermediate screen.FeedbackBottomSheet uses GhsBottomSheet with WonkySquircleShape.Sheet.Empty / loading / error:
Master proxy applies to all 3 scopes by default. Each scope has a [ Use main ] [ Custom… ] segment (review M2). "Custom…" reveals a mini-editor; "Use main" hides it.
Persistence change (review C2): the master config is its own ProxyConfig? record in ProxyRepository, plus a useMaster: Boolean per scope. The V1 promise to derive master from per-scope equality is reversed — equality-derivation is racy across DataStore writes and the user can't see "which scope am I testing." Schema migration in §4.5.
Title: "Connection"
Top intro card (outlined Radii.row):
Card 1 — Main connection (outlined Radii.row, inner Column):
ModePillSegment: No proxy / System / HTTP/HTTPS / SOCKS5 (review M3 + C3).
AnimatedVisibility.OutlinedTextFields, shape Radii.chip, with existing isLikelyValidProxyHost validation.Icons.Outlined.ContentPaste leading icon, label "Paste full URL". Opens a modal sheet (GhsBottomSheet, WonkySquircleShape.Sheet) with a single paste field. Parser accepts scheme://user:pass@host:port (scheme ∈ {http, https, socks5}). On parse success: form fields populate, sheet dismisses, snackbar "Pasted from URL." On parse fail: inline error "Couldn't read that URL."OutlinedButton, Radii.chip, leading Icons.Default.NetworkCheck. Label: "Test main connection" (review C2 — announce scope). On click: tests against 3 endpoints (search API, download CDN, configured translation provider). Results snackbar is 3-line: "Search ✓ 184 ms · Downloads ✓ 92 ms · Translation ✓ 220 ms" or per-endpoint failures.FilledTonalButton, WonkySquircleShape.CtaPrimary, leading Icons.Default.Save. Disabled until form valid. Writes to the persisted master record.Test request honors HostTokenInterceptor per review N6 — for private GH Enterprise + PAT users, the test uses the user's PAT.
Card 2 — Per-scope overrides (outlined Radii.row, inner Column):
[ Use main ] [ Custom… ] (review M2).AnimatedVisibility into a mini-editor matching Card 1 structure (mode segment + form + test + save). Test button label: "Test for Downloads" (review C2). Save here writes only to that scope.tertiaryContainer, labelSmall).tertiaryContainer, labelSmall, mono font).Empty / loading / error states:
CircularProgressIndicator (16.dp). Disable both Test and Save while testing.Direct vs No-proxy clarity (review C3): "No proxy" replaces "Direct" / "None" everywhere. Body copy updated. proxy_none_description rewritten to "The app connects to the internet without a proxy." (See §6.)
isLikelyValidProxyHost(raw). Inline error in supportingText.1..65535.ProxyRepository schema additions:
proxy_master → ProxyConfig? (nullable for "no master configured").proxy_<scope>_use_master → Boolean (default true).proxy_<scope> records remain; semantics now mean "the scope's override config" (only consulted when proxy_<scope>_use_master == false).One-time migration on first launch of V2:
ProxyConfig): write that config to proxy_master, set all 3 proxy_<scope>_use_master = true.proxy_master (ties broken by scope order: Discovery > Downloads > Translation). For each scope whose config differs from the chosen master, set proxy_<scope>_use_master = false (existing record stays as the override).tweaksDataStore.version). Run once.This is a pure-presentation migration — no network calls, no async beyond DataStore reads. Failures roll back (don't bump version key) so retry on next launch is safe.
TweaksAction additions:
OnMasterProxySave(config: ProxyConfig) — writes master + all scopes' useMaster = true.OnScopeUseMainToggled(scope: ProxyScope, useMain: Boolean) — toggles override; preserves the scope's override config in state when toggling to main.OnScopeProxySave(scope: ProxyScope, config: ProxyConfig) — writes scope override + sets useMaster = false.OnProxyTest(scope: ProxyScope?) — null scope = master (tests 3 endpoints).TweaksEntryRow — described in §2.2. Lives at feature/tweaks/presentation/components/TweaksEntryRow.kt.
ModePillSegment — promote existing ModeSegment from sections/Others.kt (private) into core/presentation/components/ModePillSegment.kt. Generic over a value type:
data class ModePillItem<T>(val value: T, val label: String, val icon: ImageVector? = null, val caption: String? = null)
@Composable fun <T> ModePillSegment(items: List<ModePillItem<T>>, selected: T, onSelect: (T) -> Unit, modifier: Modifier = Modifier)
Used by:
caption for the bottom-of-pill hint.UseMainSegment — 2-segment [ Use main ] [ Custom… ]. Lives at feature/tweaks/presentation/components/UseMainSegment.kt. Two ToggleButton-style chips, Radii.chip shape, primary fill on selected. (Could be implemented as a thin wrapper around ModePillSegment<Boolean> — implementer's call.)
RestartBanner — feature/tweaks/presentation/components/RestartBanner.kt. Outlined Radii.row tertiaryContainer-tinted Surface. Props: reasons: Set<RestartReason>, onRestartNow: () -> Unit, onLater: () -> Unit.
CustomForgesSheet — GhsBottomSheet instance, no new shape primitive. New composable at feature/tweaks/presentation/components/CustomForgesSheet.kt. Replaces CustomForgesDialog.kt.
PasteProxyUrlSheet — GhsBottomSheet. Single paste field + parser. Lives at feature/tweaks/presentation/components/PasteProxyUrlSheet.kt.
LicensesScreen — feature/tweaks/presentation/LicensesScreen.kt. Static markdown view fed by gradle-license-plugin JSON committed to assets. Outlined Radii.row row per library, expandable for full license text.
All squircles reuse Radii.row, Radii.chip, WonkySquircleShape.{CtaPrimary, CtaAlt, Search, Sheet, Dialog, Toast}.
ExpressiveCard usages inside Tweaks sections (Others.kt, Installation.kt) — replace with outlined Radii.row Surface.ElevatedCard(shape = RoundedCornerShape(32.dp)) in Translation.kt, Language.kt, About.kt, Network.kt — same replacement.RoundedCornerShape(12.dp) OutlinedTextField inside forms — switch to Radii.chip.ProxyScopeCard — deleted, replaced by §4 design.CustomForgesDialog.kt — deleted, replaced by CustomForgesSheet.Resource keys + English values. Across-13-locale translation queued via §9 CSV; English ships first per project policy.
| Resource key | Current text | Proposed text |
|---|---|---|
Res.string.tweaks_title | "Tweaks" | "Tweaks" (keep) |
Res.string.section_appearance | "Appearance" | "Appearance" (keep) |
Res.string.theme_color | "Theme color" | "Palette" |
Res.string.theme_light | "Light" | "Light" (keep) |
Res.string.theme_dark | "Dark" | "Dark" (keep) |
Res.string.theme_system | "System" | "Follow system" |
Res.string.amoled_black_theme | "AMOLED black" | "True black (AMOLED)" |
Res.string.amoled_black_description | "Use pure black for OLED screens." | "Pure-black background — saves power on OLED screens." |
Res.string.system_font | "System font" | "Use system font" |
Res.string.system_font_description | "Use the system font for the app." | "Use your device's default typeface instead of Geist." |
Res.string.scrollbar_option_title | "Show scrollbar" | "Show scrollbar" (keep) |
Res.string.scrollbar_option_description | "Show scrollbar on the right side." | "Always show the scrollbar on long pages." |
Res.string.content_width_title | "Content width" | "Content width" (keep) |
Res.string.content_width_description | "Adjust max content width." | "How wide content should stretch on big windows." |
Res.string.section_language | "Language" | "Language" (keep) |
Res.string.language_intro | "Choose your app language." | "Pick the language used across the app." |
Res.string.language_picker_title | "App language" | "App language" (keep) |
Res.string.language_picker_description | "Restart required after change." | "The app restarts when you switch language." |
Res.string.language_follow_system | "Follow system" | "Follow system" (keep) |
Res.string.language_follow_system_subtitle | (new) | "Follow system · %1$s" (resolved tag interpolated) |
Res.string.section_network | "Network" | (retired — block becomes "Connectivity" §1.1) |
Res.string.section_connectivity | (new) | "Connectivity" |
Res.string.section_privacy_and_data | (new) | "Privacy & data" |
Res.string.section_app_block | (new) | "App" |
Res.string.section_installs_and_updates | (new) | "Installs & updates" |
Res.string.connection_entry_title | (new) | "Connection" |
Res.string.connection_entry_subtitle_no_proxy | (new) | "No proxy" |
Res.string.connection_entry_subtitle_system | (new) | "System proxy" |
Res.string.connection_entry_subtitle_proxy_with_overrides | (new) | "%1$s · %2$d override" / "%1$s · %2$d overrides" (plural-aware) |
Res.string.sources_entry_title | (new) | "Sources" |
Res.string.proxy_scope_intro | "Configure proxies per scope." | (retired) |
Res.string.proxy_scope_discovery_title | "Discovery proxy" | "Search & metadata" |
Res.string.proxy_scope_download_title | "Download proxy" | "Downloads" |
Res.string.proxy_scope_translation_title | "Translation proxy" | "Translation" |
Res.string.proxy_scope_discovery_description | "Used for API and search." | "GitHub API, search results, repo details." |
Res.string.proxy_scope_download_description | "Used for APK downloads." | "APK and asset downloads." |
Res.string.proxy_scope_translation_description | "Used for translation providers." | "DeepL, Microsoft, LibreTranslate calls." |
Res.string.proxy_none | "None" | "No proxy" |
Res.string.proxy_none_description | "No proxy used." | "The app connects to the internet without a proxy." |
Res.string.proxy_system | "System" | "System" (keep) |
Res.string.proxy_system_description | "Use system proxy." | "Use the proxy configured in your OS." |
Res.string.proxy_http | "HTTP" | "HTTP/HTTPS" |
Res.string.proxy_http_caption | (new) | "Most corporate proxies." |
Res.string.proxy_socks | "SOCKS" | "SOCKS5" |
Res.string.proxy_socks_caption | (new) | "Tor, SSH tunnels." |
Res.string.proxy_test | "Test" | (retired — replaced by scope-named buttons) |
Res.string.proxy_test_main | (new) | "Test main connection" |
Res.string.proxy_test_scope | (new) | "Test for %1$s" (scope name interpolated) |
Res.string.proxy_save | "Save" | "Save" (keep) |
Res.string.proxy_test_success | "Proxy reachable (%d ms)" | "Connected in %1$d ms" |
Res.string.proxy_test_main_success | (new) | "Search ✓ %1$d ms · Downloads ✓ %2$d ms · Translation ✓ %3$d ms" |
Res.string.proxy_use_main | (new) | "Use main" |
Res.string.proxy_use_custom | (new) | "Custom…" |
Res.string.proxy_using_main_pill | (new) | "Using main" |
Res.string.proxy_paste_full_url | (new) | "Paste full URL" |
Res.string.proxy_paste_url_placeholder | (new) | "scheme://user:pass@host:port" |
Res.string.proxy_paste_url_error | (new) | "Couldn't read that URL." |
Res.string.connection_intro_title | (new) | "How the app reaches the internet" |
Res.string.connection_intro_body | (new) | "Pick a connection mode below. Most people leave this on No proxy." |
Res.string.connection_inline_helper | (new) | "Applies to all traffic unless overridden below." |
Res.string.connection_overrides_body | (new) | "Each scope uses the main connection by default. Choose 'Custom' to give a scope its own settings." |
Res.string.mirror_tweaks_entry_label | "Mirror" | "GitHub mirror" |
Res.string.custom_forges_entry_label | "Custom forges" | "Custom forges" (keep) |
Res.string.custom_forges_entry_subtitle | "Add your own Forgejo/Gitea hosts." | "Add a Forgejo or Gitea host" |
Res.plurals.custom_forges_count | (new) | "%d host" / "%d hosts" (plural-aware) |
Res.string.section_translation | "Translation" | "Translation" (keep) |
Res.string.translation_intro | "Configure translation provider and auto-translate." | "Pick the engine the app uses to translate READMEs." |
Res.string.translation_provider_title | "Provider" | "Provider" (keep) |
Res.string.translation_provider_description | "Pick a translation engine." | (retired) |
Res.string.translation_auto_title | "Auto-translate" | "Translate READMEs automatically" |
Res.string.translation_auto_subtitle | "Translate READMEs when opening repos." | "When opening a repo, translate the README into your target language." |
Res.string.section_installation | "INSTALLATION" | "Install method" |
Res.string.install_method_android_only_badge | (new) | "Android only" |
Res.string.install_method_desktop_empty_title | (new) | "Install method is Android-only" |
Res.string.install_method_desktop_empty_body | (new) | "Silent installers and installer attribution are Android features. Desktop installs go through the OS package manager." |
Res.string.installer_type_default | "Default" | "System installer" |
Res.string.installer_type_default_description | "Uses Android's default installer." | "Asks each time. Works on every device." |
Res.string.installer_type_shizuku | "Shizuku" | "Shizuku" (keep) |
Res.string.installer_type_shizuku_description | "Silent install via Shizuku." | "Silent install. Needs Shizuku app running." |
Res.string.installer_type_dhizuku | "Dhizuku" | "Dhizuku" (keep) |
Res.string.installer_type_dhizuku_description | "Silent install via Dhizuku." | "Silent install. No root needed." |
Res.string.installer_type_root | "Root" | "Root" (keep) |
Res.string.installer_type_root_description | "Silent install with root." | "Silent install via root. Power-user only." |
Res.string.installer_attribution_title | "Installer attribution" | "Pretend the installer is…" |
Res.string.installer_attribution_description | "Some apps reject silent installs unless installer claims to be Google Play." | "Some apps reject silent installs unless the installer claims to be Google Play. This setting controls what we claim." |
Res.string.section_updates | "UPDATES" | "Update behavior" |
Res.string.auto_update_title | "Auto-update" | "Install updates in the background" |
Res.string.auto_update_description | "Apply downloaded updates automatically." | "Apply downloaded updates without notifying you each time." |
Res.string.update_check_enabled_title | "Background update check" | "Check automatically" |
Res.string.update_check_enabled_description | "Periodically check for new releases." | "Look for new releases on a schedule." |
Res.string.update_check_interval_title | "Check interval" | "Check every" |
Res.string.update_check_interval_description | "How often to check for updates." | (retired) |
Res.string.update_interval_6h | (new / rename) | "Every 6 hours" |
Res.string.update_interval_12h | (new / rename) | "Every 12 hours" |
Res.string.update_interval_24h | (new / rename) | "Daily" |
Res.string.update_interval_manual | (new) | "Manual only" |
Res.string.update_desktop_check_on_launch_title | (new) | "Check on launch" |
Res.string.update_desktop_check_on_launch_subtitle | (new) | "Check for updates each time the app starts." |
Res.string.include_pre_releases_title | "Include pre-releases" | "Include pre-releases" (keep) |
Res.string.include_pre_releases_description | "Treat alpha/beta as available updates." | "Show alpha and beta tags as available updates." |
Res.string.skipped_updates_entry_title | "Skipped updates" | "Skipped updates" (keep) |
Res.plurals.skipped_updates_count | (new) | "%d app" / "%d apps" (plural-aware) |
Res.string.skipped_updates_empty_subtitle | (new) | "Nothing skipped" |
Res.string.hidden_repositories_title | "Hidden repositories" | "Hidden repositories" (keep) |
Res.plurals.hidden_repositories_count | (new) | "%d repo" / "%d repos" (plural-aware) |
Res.string.hidden_repositories_empty_subtitle | (new) | "No hidden repos" |
Res.string.storage | "Storage" | "Storage" (keep — now narrower scope) |
Res.string.downloaded_packages | "Downloaded packages" | "Downloaded APKs" |
Res.string.downloaded_packages_description | "Installer files kept for resumed updates." | "We keep installers around so updates resume fast." |
Res.string.current_size | "Current size:" | "Using:" |
Res.string.section_privacy_screen_title | (new) | "Privacy" |
Res.string.privacy_entry_subtitle | (new) | "Compact state (see §3.9 hub subtitle rules)" |
Res.string.privacy_usage_data_subheader | (new) | "Usage data" |
Res.string.privacy_telemetry_title | (new) | "Share anonymous usage data" |
Res.string.privacy_telemetry_subtitle | (new) | "Help us understand which features get used." |
Res.string.privacy_telemetry_collect_expand | (new) | "What we collect" |
Res.string.privacy_telemetry_collect_body | (new) | "App version. OS and platform. Feature usage counts. No repo names. No tokens. No identifiers." |
Res.string.privacy_telemetry_on_snackbar | (new) | "Sharing usage data starting next launch." |
Res.string.privacy_telemetry_off_snackbar | (new) | "Usage data sharing stopped. Existing data is dropped." |
Res.string.auto_detect_clipboard_links | "Auto-detect clipboard links" | "Detect repo links in clipboard" |
Res.string.auto_detect_clipboard_description | "Detect copied repo URLs and offer to open them." | "When you copy a github.com or codeberg.org link, we'll prompt to open it." |
Res.string.privacy_history_subheader | (new) | "Browsing history" |
Res.string.hide_seen_title | "Hide seen" | "Hide repos I've already viewed" |
Res.string.hide_seen_description | "Skip already-viewed repos in feeds." | "Skip seen repos in feeds and search." |
Res.string.clear_seen_history | "Clear seen history" | "Clear viewed history" |
Res.string.clear_seen_history_description | "Reset the seen-repo list." | "Forget which repos you've already opened." |
Res.string.clear_seen_history_confirm | (new) | "Clear all viewed history? This won't unstar or unfavorite anything." |
Res.plurals.host_tokens_count | (new) | "%d token" / "%d tokens" (plural-aware) |
Res.string.host_tokens_empty_subtitle | (new) | "No tokens yet" |
Res.string.section_about | "About" | "App info" (sub-screen title) |
Res.string.app_info_tagline | (new) | "Cross-platform app store for GitHub, Codeberg, and Forgejo releases." |
Res.string.app_info_action_whats_new | (new) | "What's new" |
Res.string.app_info_action_whats_new_subtitle | (new) | "Past release notes." |
Res.string.app_info_action_licenses | (new) | "Open source licenses" |
Res.string.app_info_action_licenses_subtitle | (new) | "Libraries used in the app." |
Res.string.app_info_action_privacy_policy | (new) | "Privacy policy" |
Res.string.app_info_action_privacy_policy_subtitle | (new) | "View on github-store.org." |
Res.string.app_info_action_source_code | (new) | "Source code on GitHub" |
Res.string.app_info_action_source_code_subtitle | (new) | "View this app's source." |
Res.string.app_info_version_copied | (new) | "Version copied." |
Res.string.version | "Version" | "Version" (keep) |
Res.string.feedback_send | "Send feedback" | "Send feedback" (keep) |
Res.string.feedback_hub_subtitle | (new) | "We read every report." |
Res.string.tweaks_search_placeholder | (new) | "Search settings" |
Res.string.tweaks_search_empty | (new) | "No settings match '%1$s'." |
Res.string.restart_banner_body | (new) | "Some changes need a restart to apply." |
Res.string.restart_banner_reasons_prefix | (new) | "Affected: %1$s" (comma-joined reasons) |
Res.string.restart_banner_reason_language | (new) | "language" |
Res.string.restart_banner_reason_theme | (new) | "theme" |
Res.string.restart_banner_reason_telemetry | (new) | "usage data" |
Res.string.restart_banner_restart_now | (new) | "Restart now" |
Res.string.restart_banner_later | (new) | "Later" |
Res.string.menubar_help_menu | (new, desktop) | "Help" |
Res.string.menubar_help_about | (new, desktop) | "About GitHub Store" |
Res.string.menubar_help_feedback | (new, desktop) | "Send feedback…" |
Res.string.menubar_help_licenses | (new, desktop) | "Open source licenses" |
Res.string.menubar_help_privacy | (new, desktop) | "Privacy policy" |
i18n constraints (review M6):
pluralStringResource (Compose Multiplatform Resources, org.jetbrains.compose.resources). Plural categories per locale follow CLDR (Russian 3 forms, Polish 3, Arabic 6, English 2).· for Latin-script locales, / for CJK in narrow contexts) — pragmatic compromise: use · everywhere in v1 since the existing Squiggle-adjacent text already does, queue CJK separator audit for P13.githubstore://tweaks/<category> deferred to follow-up ticket P12.5.1 (review C5 partially adopted: inline search ships, deep links queued). Route name list in §2.5.HostTokensScreen row vocabulary audit — confirm Radii.row + full-opacity outline border. Refactor in P12.5 if not.MirrorPickerScreen row vocabulary audit — same.FeedbackBottomSheet — confirm WonkySquircleShape.Sheet.TweaksAction will balloon. Recommendation (not blocking v1): split into TweaksHubAction, TweaksConnectionAction, TweaksPrivacyAction, etc., each handled by either the same VM with grouped when or per-sub-screen sub-VMs. Refactor pairs naturally with this redesign.gradle-license-plugin — verify it's configured; if not, add in this phase. Output JSON goes to composeApp/src/commonMain/composeResources/files/licenses.json. Parsed at runtime by LicensesScreen.TweaksEntryRow border passes 3:1 contrast on Cream light. If outline reads too heavy on dark themes, fall back to outline.copy(alpha = 0.55f) but only after measured contrast > 3:1 on Cream light.ProxyConfig.Socks is SOCKS5 only. Mode pill labeled "SOCKS5" to set expectation.TelemetryRepository already has a backend wired (per feature/tweaks/CLAUDE.md it's injected but UI is absent today) needs product confirmation. If backend isn't ready, the toggle persists locally + no-ops until backend ships. Worst case, it's a UX-correct placebo for one release; review C1 still satisfied because the user-facing control exists.CrashReporter (desktop) writes local logs; spec doesn't add a UI toggle for it because it's local-only (no network exfil). If a future release adds remote crash upload, gate it on the same telemetry toggle.AppLanguages.ALL list and tag-based persistence.ProxyConfig sealed class (only ProxyRepository schema bumps — see §4.5).TweaksRepository persisted prefs keys for non-renamed settings (RestartReason + needsRestartReasons is new).MirrorPickerScreen, SkippedUpdatesScreen, HiddenRepositoriesScreen, HostTokensScreen, WhatsNewHistoryScreen routes and nav wiring.FeedbackBottomSheet flow.Tokens.kt, Radii.kt, WonkySquircleShape.kt).Today's composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt Window { … } has no MenuBar content. This is additive — no current menu is being overridden.
Spec:
Window(...) {
MenuBar {
Menu("Help") {
Item("About GitHub Store", onClick = { /* navigate to App info */ })
Item("Send feedback…", onClick = { /* open FeedbackBottomSheet */ })
Item("Open source licenses", onClick = { /* navigate to App info → Licenses */ })
Item("Privacy policy", onClick = { /* open github-store.org/privacy */ })
}
}
// ... existing window content
}
macOS specifics:
Desktop.getDesktop().setAboutHandler { /* navigate to App info */ } in DesktopApp.main. The Apple-menu About item then routes to our in-app App info screen.MenuBar semantics), giving feedback + licenses + privacy a discoverable home.Windows/Linux specifics:
MenuBar renders in the title-bar area. Help menu is the entry point. No system About menu to override.i18n: menu strings are added to strings.xml per §6 (menubar_help_menu, menubar_help_about, etc.). At runtime, the JVM Menu and Item labels are resolved via the same string catalog used by Compose UI.
Why this matters: every long-tail desktop app — VS Code, Slack, Discord, 1Password — exposes About via the native menu bar. Today our Compose Multiplatform Window has no menu, so desktop users hunt for About in the in-app UI. Wiring this is ~25 lines + zero changes to mobile.
Generated as /Users/rainxchzed/Documents/development/kmp/GitHub-Store/.design/P12_5_TWEAKS_STRINGS.csv alongside this spec. Three-column shape: Resource key | Old English | New English. New keys list as old="(new)".
CSV is the source of truth for the translator queue. English ships first per project policy; translations land in subsequent commits as translators turn each locale around.
Suggested wave-based parallelization for gsd-execute-phase:
ProxyRepository master + per-scope useMaster, §4.5).RestartReason enum + needsRestartReasons in TweaksRepository.RestartBanner component (§5.1).TweaksEntryRow primitive (§2.2).TweaksScreen hub composable with restart banner + search field + section blocks (§2).TweaksAppearanceScreen (§3.1) — easy, mostly relocation.TweaksLanguageScreen (§3.2) — searchable list pattern.TweaksStorageScreen (§3.8) — narrower than V1's Library cleanup; just downloaded APKs.TweaksAppInfoScreen (§3.11) — meta links + version card.LicensesScreen (new, §3.11 / §5.1) — requires gradle-license-plugin config.TweaksConnectionScreen (§3.3 + §4).ModePillSegment promotion (§5.1).UseMainSegment (§5.1).PasteProxyUrlSheet (§5.1).TweaksPrivacyScreen (§3.9) — telemetry opt-out is highest-priority new control.TweaksUpdatesScreen (§3.7) — drops 3h interval.TweaksSourcesScreen (§3.4) + CustomForgesSheet (§5.1).TweaksTranslationScreen (§3.5) — provider radio redesign.TweaksInstallScreen (§3.6) + Android-only badge handling for desktop.FeedbackBottomSheet directly (§3.12).sections/Appearance.kt, sections/Language.kt, sections/Network.kt, sections/Translation.kt, sections/Installation.kt, sections/Others.kt, sections/About.kt, sections/SettingsSection.kt, components/CustomForgesDialog.kt.MenuBar in DesktopApp.kt (§8).Desktop.setAboutHandler wiring.End of spec.