Skip to content

Unbounded as top-level tab on the home screen (Part 2/2)#8820

Open
myleshorton wants to merge 15 commits into
fisk/smc-unified-screenfrom
fisk/unbounded-tab
Open

Unbounded as top-level tab on the home screen (Part 2/2)#8820
myleshorton wants to merge 15 commits into
fisk/smc-unified-screenfrom
fisk/unbounded-tab

Conversation

@myleshorton
Copy link
Copy Markdown
Contributor

Summary

Adds Unbounded as a top-level tab at the top of the home screen — its own surface alongside the VPN tab, distinct from the unified Share My Connection screen shipped in [Part 1].

Where the SmC screen is opt-in, settings-buried, explicit-disclosure (the user actively chooses to be a peer), the Unbounded tab is visible, auto-enabled, low-friction (Unbounded contributes by default once the server-side flag is on, with an easy hide-tab opt-out). The two surfaces share the same underlying broflake widget-proxy in radiance — this PR is purely about exposing it as a top-level surface and shaping the auto-enable behavior.

This is Part 2 of 2 of the original #8740 split, stacked on Part 1 ([8740-B]).

What's in this PR

Tab surface (Phases 1–4):

  • Phase 1 — Tab shell. Unbounded added as a top-level tab alongside VPN; gated on Flutter's Platform capability check so unsupported platforms skip it cleanly.
  • Phase 2 — Settings sheet + hide-tab toggle. Users who don't want the tab visible can hide it via Settings.
  • Phase 3 — Auto-enable on VPN connect. First time the user connects to VPN, Unbounded auto-enables (mediated by a "has the user opted out" check).
  • Phase 4 — First-visit welcome popup + info bubble. One-shot, with persistence so it doesn't reappear.

Auto-enable refinement:

  • Auto-enable on app launch as well, not just on VPN connect — covers the case where Unbounded was active and the user restarts the app.

Visual polish:

  • Heart-spray (Lottie) for remote-client arrivals, iterated from inside-the-pill → overflowing-pill → on-globe; landed on "on-globe with floating toast" matching the SmC screen.
  • "Total people helped to date" counter, persisted across restarts so users see cumulative impact.

Server gating:

  • Entire Unbounded UI surface (tab, settings entry, auto-enable hooks, project link) is gated on the server-side Features[unbounded] flag. When false (the default for censored regions) the tab and all associated UI disappear — censored users should never see a "share your connection" surface that could draw on-device attention.

SmC → Unbounded fallback:

  • If peer.Client.Start fails for any reason (UPnP denied, port collision, lantern-cloud unreachable), the SmC screen auto-falls back to Unbounded mode. User sees a "trying Basic mode instead" notice rather than a hard error.

Final SmC polish:

  • Lottie heart-burst rendered at native canvas size (was being downscaled).
  • Heart-to-text gap nudged 10 → 14 px to match design.

How this was sliced

Cherry-picked from the original fisk/share-my-connection-ux (#8740) — chronological commits 22 through 35, stacked on top of [8740-B]'s 21 commits. Reviewers see the Unbounded-tab-specific diff only.

Why both surfaces (tab + SmC screen)

The unified SmC screen is for users who want to think about being a peer; it makes the act explicit and gives them controls. The Unbounded tab is the low-friction, default-on path for everyone else — it's Unbounded contributing in the background with an easy way to turn it off. Different products serving different segments of the user base.

Reconciling the two surfaces is a follow-up product decision (does turning Unbounded off in one place turn it off in the other? does the SmC screen "Basic mode" pick stick across visits to the tab?). For MVP, both ship, and operations are independent.

Test plan

  • flutter analyze clean.
  • go build ./lantern-core/... clean.
  • macOS end-to-end:
    • Open app → Unbounded tab present at top of home (with feature flag on).
    • Auto-enable on first VPN connect → broflake widget proxy starts running.
    • Restart app → Unbounded re-enables on launch.
    • Hide tab via Settings → tab disappears from home.
    • Toggle server-side Features[unbounded] off → entire Unbounded UI surface disappears (tab, settings entry, auto-enable disabled).
    • SmC screen + Unbounded tab both visible → confirm they don't fight each other when toggled.
  • Fallback path: force peer.Client.Start to fail (e.g., block UPnP) → SmC screen toggles to "trying Basic mode instead", broflake takes over.

Dependencies

🤖 Generated with Claude Code

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Part 2/2 of the Unbounded UX split: lifts Unbounded from the buried Share-My-Connection screen onto its own top-level Home tab, adds a Settings sub-page (auto-enable + hide-tab), a one-shot welcome dialog, a persisted lifetime "people helped" counter, and a transparent SmC→Unbounded fallback when peer.Client.Start errors. Gated end-to-end on the server-side Features[unbounded] flag.

Changes:

  • Refactor Home into a two-tab shell (VPN + Unbounded) with status-dot tab labels; lift VPN body into vpn_tab.dart.
  • Add UnboundedSetting page + appSettingProvider fields (unboundedAutoEnable, unboundedHidden, unboundedWelcomeSeen, unboundedTotalHelped) and wire auto-enable on app launch and VPN-connect.
  • Rework the share screen body into UnboundedTab with persisted total counter, "Waiting for connections…" idle pill, full-canvas Lottie heart-burst, info bubble + welcome dialog, and a _handlePeerStatus-driven SmC→Unbounded fallback path.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
lib/features/share_my_connection/share_my_connection.dart Renames screen to UnboundedTab, adds autoStart, persisted total, SmC→Unbounded fallback, welcome dialog, restyled arrival/idle toasts
lib/features/home/home.dart Rewrites Home as a tab shell with auto-enable useEffect + VPN-connect listener and i18n-preserved telemetry dialog
lib/features/home/vpn_tab.dart New file extracting the VPN tab body from old Home
lib/features/setting/setting.dart Adds Unbounded Settings menu entry and gates the Unbounded promo card on the new feature flag
lib/features/setting/unbounded_setting.dart New Settings sub-page exposing auto-enable + hide-tab toggles
lib/features/setting/vpn_setting.dart Removes the in-VPN-settings "Share My Connection" tile (moved to tab)
lib/features/home/provider/app_setting_notifier.dart Adds setters for the four new Unbounded preferences
lib/core/models/app_setting.dart Adds persisted fields for Unbounded prefs and lifetime total
lib/core/models/feature_flags.dart Adds unbounded server-side feature flag enum entry

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/features/share_my_connection/share_my_connection.dart
Comment on lines +115 to +141
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!unboundedAvailable) return;
final appSetting = ref.read(appSettingProvider);
if (!appSetting.onboardingCompleted) return;
if (!appSetting.unboundedAutoEnable) return;
final share = ref.read(shareProvider);
if (share.active || share.probing) return;
ref.read(shareProvider.notifier).autoStart(ref);
});
return null;
}, [unboundedAvailable]);

ref.listen<VPNStatus>(vpnProvider, (prev, next) {
if (prev == next) return;
if (next != VPNStatus.connected) return;
if (!unboundedAvailable) return;
final autoEnable =
ref.read(appSettingProvider).unboundedAutoEnable;
if (!autoEnable) return;
final share = ref.read(shareProvider);
if (share.active || share.probing) return;
// Defer to avoid mutating provider state inside the listen callback.
Future.microtask(
() => ref.read(shareProvider.notifier).autoStart(ref),
);
});
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-analyzed and I think the inner guard in autoStart already handles this. Trace:

useEffect's postFrameCallback fires (or microtask, whichever first)
  → caller-side guard reads share (false, false) → passes
  → calls autoStart UNAWAITED (autoStart returns a Future)
  → autoStart's first statement: 'if (state.active || state.probing) return;' → passes
  → next statement (synchronous): 'state = state.copyWith(probing: true);'
  → first await ('_start' eventually awaits PatchSettings) → yields control
Now the OTHER scheduler fires:
  → caller-side guard reads share (false, TRUE now) → bails
  → never even calls autoStart

Dart isolates are single-threaded — there's no preemption between autoStart's guard and the probing=true set; they run synchronously in the same microtask hop. The only way both would pass is if there's a yield between them, and there isn't.

That said, the combined fix from #3321840455 (autoStart now does a single unconditional _start(unbounded) rather than mixing manual-port lookup + probe + mode selection) further shortens the synchronous prefix, so even hypothetical scheduling oddities have less room to interleave. Going to leave the home.dart caller-side guards as-is; happy to consolidate into a single helper in a follow-up if the dual-entry-point pattern grows.

Comment thread lib/features/share_my_connection/share_my_connection.dart
Comment thread lib/features/share_my_connection/share_my_connection.dart Outdated
Comment thread lib/features/setting/unbounded_setting.dart
Comment thread lib/features/setting/setting.dart Outdated
Comment on lines +571 to +580
useEffect(() {
final seen = ref.read(appSettingProvider).unboundedWelcomeSeen;
if (!seen) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
showUnboundedWelcomeDialog(context, ref);
});
}
return null;
}, const []);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushing back: the empty deps list is intentional, not an oversight.

The welcome dialog is a once-per-device first-visit primer (persisted via appSettingProvider.unboundedWelcomeSeen). The design contract is:

  1. First visit on a clean install → dialog auto-fires once.
  2. Re-openable anytime via the info-bubble icon (already wired).
  3. Sign-out + sign-in or settings reset → flag clears → next launch fires the dialog again on next Home mount.

What the dialog explicitly should NOT do: re-fire WITHIN the same Home lifetime when the flag is reset programmatically (e.g. user opens it via the info bubble, the whenComplete sets seen=true, but if they immediately reset settings via a debug menu, we don't want the dialog to pop again in the same session — that would be jarring).

The current shape (useEffect(..., const []) + read on mount) gives exactly that. Watching the provider would re-fire the dialog when the flag transitioned from true→false within the same lifetime, which isn't desired.

If a future design wants the dialog to re-fire on sign-out within the same session, the right place would be the sign-out handler itself (push a Navigator route or similar), not the welcome-dialog useEffect.

@myleshorton myleshorton force-pushed the fisk/smc-unified-screen branch from f7e65ef to a190859 Compare May 29, 2026 19:58
@myleshorton myleshorton force-pushed the fisk/unbounded-tab branch from 8fa541b to ff6cce1 Compare May 29, 2026 20:00
myleshorton added a commit that referenced this pull request May 31, 2026
share_my_connection.dart:
- autoStart unconditionally starts ShareMode.unbounded, dropping
  the manual-port and UPnP-probe branches. The auto path is the
  low-friction Unbounded-only surface; SmC requires the explicit
  disclosure dialog enforced by toggle(). Without this fix, a
  user who'd configured a manual port (or got lucky on the mock
  probe) would silently land in SmC mode without ever seeing the
  disclosure, turning their device into a residential exit they
  never agreed to.
- _fallbackToUnbounded: added a comment documenting the
  intentional reuse of the prior _start's event subscription. The
  invariant works because the error path stays inside the same
  subscription; flipping state.mode keeps the same forwarder
  pushing events for Unbounded. _stop is the only teardown path.
- Welcome dialog 'restrictors' → 'restrictions' typo; normalized
  the surrounding paragraph to single-quote-delimited strings
  (the inner curly quote in 'digital bridges' is retained — it's
  intentional inside the prose).
- About-bubble tooltip moved through .i18n.

unbounded_setting.dart + setting.dart:
- New i18n keys added to en.po:
    unbounded_settings_title
    auto_enable_unbounded / auto_enable_unbounded_subtitle
    hide_unbounded / hide_unbounded_subtitle
    about_unbounded
  All hardcoded English strings in the new screens replaced with
  .i18n lookups, matching the existing pattern in vpn_setting.dart.

dart analyze clean on the touched files.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adam Fisk and others added 15 commits May 31, 2026 16:47
Restructures Home into a two-tab shell (VPN + Unbounded) per the
Figma spec at figma.com/design/hNlyYToB5TnX9SDBFDYJTq?node-id=2403-19287
and tracking ticket getlantern/engineering#3455. Previously the
peer-share UI sat behind a "Share My Connection" entry on the VPN
settings screen that opened it as a modal; the spec elevates it to a
peer of the VPN view.

- New lib/features/home/vpn_tab.dart: VpnTab body lifted from the
  old Home (toggle, data usage, location, routing, split tunneling).
  Scaffold/AppBar moved up to the shell.
- home.dart: Home becomes the tab shell. AppBar hosts the Lantern
  logo, settings menu, account/sign-in actions, plus a TabBar with
  green/grey-dot tab labels (green when feature enabled per spec).
  Onboarding, macOS sysext, and telemetry-consent init preserved
  inside the shell so launch behaviour is unchanged.
- share_my_connection.dart: ShareMyConnectionScreen renamed to
  UnboundedTab, BaseScreen wrapper dropped (shell provides chrome).
  Description text updated to the spec's
  "Help others bypass censorship by securely sharing your
  connection."
- Arrival toast copy updated to match the spec:
  "Helping a new person in <country>" while a peer is arriving,
  "Waiting for connections..." in the idle state (new _WaitingCard).
- vpn_setting.dart: SmC modal entry removed — there is no longer a
  Share-My-Connection tile here. Unused peerProxy watch dropped.

Followups (separate phases): Unbounded Settings sheet (Auto-enable
+ Hide Unbounded toggles), auto-enable on VPN connect, first-visit
Welcome popup. Files/class names still say "share_my_connection"
and "ShareNotifier" to keep this diff focused; rename to
"unbounded" is a polish step at the end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the Unbounded Settings sheet from the Figma spec
(figma.com/design/hNlyYToB5TnX9SDBFDYJTq?node-id=2403-19287),
reached from the main Settings menu (between VPN Settings and
Language). Two toggles:

- Auto-enable Unbounded — defaults on, subtitle "Turn on
  automatically when Lantern is open". The actual auto-enable
  wiring (listening to vpnProvider and toggling peer-proxy) lands
  in phase 3.
- Hide Unbounded — defaults off, subtitle "Removes Unbounded from
  the top of this screen". When on, the Home shell hides the
  Unbounded tab AND collapses the tab strip entirely (single-tab
  case), falling back to rendering VpnTab directly.

State persistence via AppSetting:
- unboundedAutoEnable (default true)
- unboundedHidden (default false)
- unboundedWelcomeSeen (default false) — added now, used in phase 4

All three round-trip via toJson/fromJson and the new
setUnboundedAutoEnable / setUnboundedHidden / setUnboundedWelcomeSeen
notifier methods.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the "Auto-enable Unbounded" toggle in Unbounded Settings is on
(default per phase 2), Unbounded turns on automatically the moment
the VPN reaches the connected state — per the Figma spec and ticket
getlantern/engineering#3455 ("turns on automatically when Lantern
connects").

- New ShareNotifier.autoStart(): public, programmatic entry point
  that mirrors the toggle() probe-then-start path but skips the
  disclosure dialog because the user has already opted in via
  settings. No-ops if already active or probing.
- Home shell uses ref.listen<VPNStatus>(vpnProvider, ...) to detect
  the disconnected → connected transition. On match, reads the
  auto-enable flag and current share state, then calls autoStart in
  a microtask so we don't mutate provider state from inside the
  listen callback.

Disconnect path is left alone — turning Unbounded off when the VPN
drops would be surprising; the user can toggle it off manually.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the "Welcome to Unbounded" first-visit explainer dialog per
Figma (figma.com/design/hNlyYToB5TnX9SDBFDYJTq?node-id=2403-19287).
Fires automatically the first time the user opens the Unbounded
tab, then never again — gated on unboundedWelcomeSeen (added to
AppSetting in phase 2). The info-bubble icon in the tab header
re-opens the same dialog so users can revisit the explanation.

- New showUnboundedWelcomeDialog(context, ref): wraps a Dialog with
  the spec's heart-Lantern logo (re-using _HeartPainter), title,
  three-paragraph explainer body, and Learn more + Got it buttons.
  Dismissal (either button or scrim tap) flips welcomeSeen true via
  whenComplete so a single completion path handles both.
- UnboundedTab.useEffect runs once on mount, schedules the dialog
  in a post-frame callback when welcomeSeen is false.
- Description text row now also hosts an Icons.info_outline button
  to the right that calls showUnboundedWelcomeDialog directly.

"Learn more" link is a no-op stub for now — wiring it to the public
Unbounded explainer URL is a tiny followup once the URL is decided.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Unbounded Settings subtitle reads "Turn on automatically when
Lantern is open" — which is app-launch, not VPN-connect. Phase 3
only handled the VPN-connect transition, so a user who launches the
app and never connects the VPN would never see Unbounded auto-start
despite the toggle being on.

Adds a second entry point: a post-frame useEffect on Home mount
that reads autoEnable + onboardingCompleted, and calls
ShareNotifier.autoStart if conditions hold. The existing
ref.listen<VPNStatus> path stays in place for the case where the
toggle flipped on after launch or the user connects the VPN later.

Both paths gate on (active || probing) to avoid re-triggering
mid-flight and skip the disclosure dialog since settings opt-in is
the consent gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
unbounded.lantern.io shows dozens of pink hearts spraying outward
across the whole globe area on each arrival — not a single burst
cramped inside the toast pill. Watching unbounded-russia.mp4 made
it clear my previous implementation had the wrong scale: the Lottie
was confined to a 40×40 slot inside the pill, so all the particle
spray got clipped.

Restructure:
- New _LottieBurstLayer: a Positioned.fill overlay on top of the
  globe (sibling to _GlobeView, parent Stack now clipBehavior:
  Clip.none). Subscribes to ShareNotifier.connectionEvents and
  bumps a burstId counter on each non-replay state=1. The inner
  _BurstAnimation widget gets a fresh ValueKey per burst so the
  Lottie restarts from frame 0; the previous Lottie's
  AnimationController is disposed when the State unmounts.
- _ArrivalCard simplified: replaces the embedded _HeartBurst with
  a static _HeartPainter heart, matching unbounded's pill chrome
  (small heart icon + text, no animation inside the pill).
- _HeartBurst class removed.

Result: the hearts now spread across the entire globe Stack area
instead of being trapped inside a 40×40 box.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous approach made the Lottie a globe-wide Positioned.fill layer.
unbounded.lantern.io actually anchors the Lottie INSIDE the toast
pill's heart slot, with absolute-positioned negative offsets so it
overflows up and to the right into the globe area:

  LottieContainer { position: relative; width: 32px; height: 27px; }
  LottieWrapper   { position: absolute; bottom: -55px; left: -105px;
                    width: 420px; }

Translating one-to-one in Flutter: the pill's heart slot is a Stack
with clipBehavior: Clip.none, containing the static _HeartPainter
centered + a Positioned _ArrivalLottie at bottom: -55, left: -105,
width: 420, height: 420. The pill Container itself also uses
clipBehavior: Clip.none so the Lottie can spill past the rounded
borders.

Side benefits:
- The burst now follows the pill — when AnimatedSwitcher swaps to a
  new arrival card, the Lottie restarts naturally because each card
  has its own _ArrivalLottie state (no need for the burstId counter
  + the standalone _LottieBurstLayer, both deleted).
- The burst origin is anchored at the pill's heart, so hearts spray
  from a single, semantically-meaningful point instead of
  centre-of-globe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Compared the current implementation against frame-020.png of
unbounded-russia.mp4:

- The pill in unbounded is just [heart icon] + text, no flag emoji.
  Removed the flag prefix so the pill width stays manageable and
  the layout reads identically. flagEmoji is still on the event for
  future use (label above the arc, etc).
- Anchor the pill at the bottom-LEFT of the globe area, not
  centered. Position changes from (left: 0, right: 0, child:
  Center(...)) to (left: 12, bottom: 8, child: ...).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous commit moved the pill to bottom-left, overshooting the fix
for the cut-off text — the actual cause was the extra flag-emoji
width, which is already removed. Restoring (left: 0, right: 0,
child: Center(...)) so the pill sits under the globe's centre per
frame-020 of unbounded-russia.mp4. Static heart in the pill stays
visible (also matches unbounded) and continues to anchor the Lottie
burst origin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The stat was an in-memory counter that reset on every app launch and
on every off→on toggle. Spec wording ("Total people helped to date")
implies lifetime — survives both.

- AppSetting gains unboundedTotalHelped (int, default 0) + the
  matching setUnboundedTotalHelped notifier method. Round-trips via
  toJson/fromJson.
- ShareNotifier.build() seeds totalCount from the persisted value
  instead of starting at 0.
- _start and _stop now preserve state.totalCount across toggle
  cycles (were overwriting with ShareState() defaults).
- On each new-peer arrival, after incrementing totalCount, write the
  new value via setUnboundedTotalHelped so the persisted value stays
  in sync. SharedPreferences I/O is fine — peer arrivals are bursty,
  not continuous.
- Stat labels updated to the Figma copy: "People helping right now"
  (was "Active now") and "Total people helped to date" (was "Total
  today" — which was inaccurate even before persistence).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
peer.Client.Start failures (UPnP miss, /v1/peer/register 404/4xx/5xx,
samizdat verify timeout) arrive in Dart as a peer-status FlutterEvent
with phase=error. Until now those rendered raw inside the SmC status
card ("Couldn't share: register with lantern-cloud: register: peer api:
status=404 body=404 page not found"), which is both ugly and inactionable.

Now `_handlePeerStatus` detects phase==error with mode==SmC and
transparently switches to Unbounded via setUnboundedEnabled(true).
The user's intent — "I want to share" — is honoured via broflake
regardless of SmC's outcome. UPnP failure is the common case; treating
it as a routine fallback rather than an error matches the design
expectation that UPnP works only some of the time.

State is rebuilt with ShareState() directly (rather than copyWith) so
errorMessage clears — copyWith's `?? this.errorMessage` would otherwise
keep the stale SmC failure string visible after the fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Censored users should not see a "share your connection" UI on their
device — it can be a red flag on-device evidence even when broflake
itself is server-gated off. Mirror the radiance shouldRunUnbounded
gate up into Flutter so the Unbounded tab, settings sub-page, project
promo tile, first-visit welcome dialog, and auto-enable hooks all
disappear when Features[unbounded] is false.

Adds FeatureFlag.unbounded backed by the same "unbounded" key the
server already emits (common/types.go UNBOUNDED). Default getBool(...)
is false, so any user whose /v1/config-new response omits the flag
(no connectivity, parse failure, censored region) sees the safe state:
no Unbounded UI at all.

The user's "Hide Unbounded tab" toggle (appSettingProvider
unboundedHidden) still wins on top of this for non-censored users who
want it hidden. The new effective predicate is
unboundedAvailable && !unboundedHidden.

The welcome dialog at share_my_connection.dart:572 and the info-bubble
re-opener at :607 are both inside UnboundedTab.build, which never
mounts when the tab is hidden, so no defensive code is needed there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lottie's explosion.json is 420×502; we were forcing it into a 420×420
Positioned with BoxFit.contain, which uniform-scaled the animation
down by ~83% and lopped 82 px off the upward spread. End result: the
hearts clustered tightly just above the pill instead of fanning out
across the globe the way unbounded.lantern.io's CSS renders them
(width:420 with height:auto preserves the native aspect ratio).

Set height to 502 to match the native canvas exactly. Width and the
bottom/left negative offsets stay the same — the bottom of the
Lottie still anchors 55 px below the pill heart's bottom and 105 px
left of its left edge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pill's static heart was sitting a touch close to the "H" in
"Helping a new person in <country>". 4 px is the smallest visibly
noticeable nudge — large enough to ease the crowding without making
the pill feel padded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
share_my_connection.dart:
- autoStart unconditionally starts ShareMode.unbounded, dropping
  the manual-port and UPnP-probe branches. The auto path is the
  low-friction Unbounded-only surface; SmC requires the explicit
  disclosure dialog enforced by toggle(). Without this fix, a
  user who'd configured a manual port (or got lucky on the mock
  probe) would silently land in SmC mode without ever seeing the
  disclosure, turning their device into a residential exit they
  never agreed to.
- _fallbackToUnbounded: added a comment documenting the
  intentional reuse of the prior _start's event subscription. The
  invariant works because the error path stays inside the same
  subscription; flipping state.mode keeps the same forwarder
  pushing events for Unbounded. _stop is the only teardown path.
- Welcome dialog 'restrictors' → 'restrictions' typo; normalized
  the surrounding paragraph to single-quote-delimited strings
  (the inner curly quote in 'digital bridges' is retained — it's
  intentional inside the prose).
- About-bubble tooltip moved through .i18n.

unbounded_setting.dart + setting.dart:
- New i18n keys added to en.po:
    unbounded_settings_title
    auto_enable_unbounded / auto_enable_unbounded_subtitle
    hide_unbounded / hide_unbounded_subtitle
    about_unbounded
  All hardcoded English strings in the new screens replaced with
  .i18n lookups, matching the existing pattern in vpn_setting.dart.

dart analyze clean on the touched files.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@myleshorton myleshorton force-pushed the fisk/unbounded-tab branch from 03e78f5 to 1809538 Compare May 31, 2026 22:51
myleshorton added a commit that referenced this pull request Jun 1, 2026
Two Copilot fixes (+ a third pushback in the reply):

1. _resolveAndEmit awaits peerLookup. If the notifier is disposed
   during the await, _eventController.close() has already run; the
   subsequent _eventController.add would throw 'Bad state: Cannot
   add event after closing'. Added an isClosed check after the
   await before the identity check.

2. smc_stat_total_today msgstr read 'Total today' but the
   ShareState.totalCount is session-scoped (reset on every
   toggle-on, no day bucket, no persistence). Renamed to 'Total
   this session' so the label matches the implemented semantics.
   #8820's rebase later replaces this key with smc_stat_total_helped
   + 'Total people helped to date' alongside persistence via
   unboundedTotalHelped — until then the more honest 'session'
   wording matches reality.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants