Skip to content

Polish mobile Settings layout and Usage placement - #786

Closed
marksftw wants to merge 9 commits into
mobile-navigationfrom
mobile-settings-layout
Closed

Polish mobile Settings layout and Usage placement#786
marksftw wants to merge 9 commits into
mobile-navigationfrom
mobile-settings-layout

Conversation

@marksftw

@marksftw marksftw commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Summary

  • align compact Settings typography, spacing, touch targets, safe areas, and scroll ownership with the mobile navigation shell
  • add balanced selection-pill padding around compact Settings rows without moving their icon and label alignment
  • prevent mobile WebKit from latching Settings-row hover during touch scrolling; compact rows now paint only the genuine current route with semantic light/dark selected colors
  • prevent compact chat and project ellipsis triggers from latching a selected color during scrolling while retaining genuine menu-open and keyboard-focus states
  • move automatic route focus from the full-width Back action to outline-free Settings landmarks, removing the leaked blue browser focus box while preserving deliberate keyboard focus rings
  • pin the compact detail navigation below the native safe area and isolate page scrolling beneath its opaque header so content cannot enter the status-bar region
  • keep one detail-content scroll owner across compact and desktop breakpoints so rotation or resize preserves scroll position
  • move the compact Settings control into the top-right header and let chat history use the reclaimed height
  • extend the native compact chat-history canvas, fade, and scrollbar to the physical device bottom while retaining safe final-row clearance above the home indicator
  • relocate the shared Usage card from both home menus to the bottom of Settings on compact and desktop, after account details and Log out
  • refresh billing exactly once per Settings entry while deduplicating direct-detail and StrictMode observers and preserving same-account cached content
  • scope billing state and query data to the authenticated account so logout/account transitions cannot expose a previous account plan
  • constrain long plan names and keep the relocated Usage target between the former Settings click location and Log out
  • center keyboard-focused native iOS dialogs in the live VisualViewport and resolved safe area, keeping Create/Rename Project, Rename Chat, and Custom Instructions titles and helper text visible
  • keep tall native dialogs scrollable through the Radix content shard while preserving existing desktop/web positioning and specialized dialog size/overflow rules

Stack

Validation

  • nix develop .#ci -c ./scripts/ci/frontend.sh
    • 754 tests passed
    • TypeScript and formatting passed
    • ESLint: 0 errors; 13 pre-existing warnings
  • MAPLE_WEB_ENVIRONMENT=pr nix develop .#ci -c ./scripts/ci/web.sh
    • reproducible PR web build passed
    • web archive SHA-256: 268225a7c815113fbae2b151177c3f323b43939a66ff0a34f35e0b7e7c57e75f
  • production Vite build, pre-commit checks, and git diff --check passed
  • focused compact/desktop row-state, chat/project overflow-menu presentation, exclusive-menu focus restoration, placement, long-plan, refresh, re-entry, direct-detail, StrictMode, stale-data, logout, account-switch, safe-area history-tail, mobile navigation, dialog safe-area geometry, VisualViewport resize/scroll lifecycle, first-render placement, Radix scroll ownership, and dialog-presentation tests passed

Authenticated post-fix screenshot QA remains unavailable in this workspace: the managed workspace CLI, test-user manifest, service listeners, local Maple app bundle, and physical iOS device session are absent. The supplied iPhone screenshots and recordings were inspected frame by frame to identify the doubled safe-area offset, content bleed, automatic focus outline, latched touch-hover states, premature bottom-safe-area history cutoff, and keyboard-driven dialog displacement. Exact source/CSS review covered 390×844, 430×932, 844×390, 767/768 breakpoint behavior, 1280px desktop/short-height layouts, Dynamic Island safe bounds, and short/tall dialog geometry. A physical iPhone keyboard pass remains the final runtime check for the new dialog placement.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 15, 2026

Copy link
Copy Markdown

Deploying maple with  Cloudflare Pages  Cloudflare Pages

Latest commit: 5b79e27
Status: ✅  Deploy successful!
Preview URL: https://784764d6.maple-ca8.pages.dev
Branch Preview URL: https://mobile-settings-layout.maple-ca8.pages.dev

View logs

@marksftw

Copy link
Copy Markdown
Contributor Author

Manual QA

Manual QA completed. Unless otherwise noted, each item was verified in the mobile app.

  • Main navigation works and scrolls correctly.
  • The Settings button appears in the top-right on mobile.
  • The Settings button remains in the bottom-left on web and desktop.
  • The Usage meter appears at the bottom of the Settings screen.
  • Swipe navigation works across chats, the main navigation, and Settings navigation.
  • The Settings screen uses text sizing and layout consistent with the main navigation.
  • Dialogs center correctly with the keyboard both visible and hidden.
  • CRUD operations for projects, chats, and pinned chats work correctly.
  • Project custom instructions work correctly.

@marksftw

Copy link
Copy Markdown
Contributor Author

Independent code review

Review complete. Three independent passes covered state/security, mobile UI, and minimality/tests. I found one merge-blocking account-isolation defect, one moderate navigation issue, and several clear simplification opportunities.

Findings

  1. [P1] Cross-account billing data can leak after logout/account switch

    The new account-keyed query in frontend/src/billing/useBillingStatusQuery.ts:46-53 assumes the billing request belongs to its accountId, but frontend/src/billing/billingService.ts:68-71 stores credentials in a mutable singleton without account ownership or invalidation of pending token generation.

    Reproduced sequence:

    • Account A starts token generation.
    • Logout clears the token and the service switches to B.
    • A's delayed generation resolves and rewrites the shared token.
    • B's billing request authenticates with A's token.

    Observed:

    {"storedToken":"account-a-token","statusSeenByB":"Account A Pro"}

    This can expose A's plan/usage and authenticate billing-portal operations as A. The singleton behavior predates this branch, but it directly defeats this branch's new account-isolation guarantee.

    Fix the credential lifecycle at the service boundary: make tokens account/epoch-owned and ensure clearToken or an OpenSecret context change invalidates pending generation before it can store or use its result. Replace the mocked logout coverage at frontend/src/components/settings/useSettingsBillingRefresh.test.tsx:446-487 with coverage through the real service boundary.

  2. [P2] The relocated Usage link bypasses Settings navigation locking

    The Usage link at frontend/src/components/settings/SettingsLayout.tsx:799-809 remains active during sign-out or another locked Settings operation. Unlike normal Settings links, it lacks aria-disabled and a guarded click handler.

    Compact layouts happen to have a history blocker, but that blocker is desktop-disabled at SettingsLayout.tsx:452-499. On desktop, clicking Usage during asynchronous logout can unmount Settings, allowing cleanup to continue while any resulting error becomes invisible.

    The fix is small: apply the same disabled state and click guard used by SettingsNavLink.

Simplification and cruft

  • Inline the trivial one-owner presentation seams and remove their class-string tests:
    • frontend/src/components/accountMenuPresentation.ts
    • frontend/src/components/settings/settingsNavPresentation.ts
    • frontend/src/components/chatHistoryMenuPresentation.ts
  • frontend/src/components/settings/useSettingsBillingRefresh.ts is a one-callsite wrapper around useBillingStatusQuery. Its re-export and aliases are test-only. Call the shared hook directly and keep only meaningful account/race tests beside it.
  • Remove low-value source/class assertions:
    • frontend/src/components/AccountMenu.test.tsx:26-33
    • frontend/src/components/Sidebar.test.tsx:48-61
    • frontend/src/components/chatHistoryMenuPresentation.test.ts:28-32
    • frontend/src/components/CreditUsage.test.tsx, which only checks Tailwind tokens and forced CreditUsageView to become public.
  • Remove the explanatory comments added by this branch to satisfy the no-comments requirement. They occur in the billing hooks, AccountMenu, API Settings, settings presentation, and index.css.
  • Tiny safe cleanups include the obsolete single-child flex/gap wrapper in AccountMenu.tsx:51 and redundant ref/cn() indirection in Sidebar.tsx:251,534.

Keep the real account-transition and VisualViewport geometry/subscription tests; those protect meaningful behavior.

Validation

  • Independently reproduced the cross-account token race.
  • Full frontend CI passed: 754 tests, formatting, and TypeScript; 13 pre-existing lint warnings.
  • Canonical PR web build passed.
  • git diff --check passed and the worktree remained clean.
  • No additional mobile/UI defect met the fix bar. Physical iOS keyboard and VoiceOver behavior remain unverified.

@marksftw

Copy link
Copy Markdown
Contributor Author

Review response

Addressed in b196f66.

Accepted findings

  • P1 confirmed and fixed. I reproduced the Account A → logout/switch → late token → Account B request sequence against the real BillingService boundary. Billing credentials are now stored with their account owner and fenced by a revocable epoch. Clearing credentials or changing accounts invalidates pending generation and pending API results; ownership is rechecked after each async boundary, and billing-status queries pass their expected account into the service so a stale query cannot run as the newly current user. Legacy raw credentials are discarded.
  • P2 confirmed and fixed. The relocated Usage link now mirrors Settings navigation locking with aria-disabled, a guarded click handler, and disabled styling during shared locks, sign-out, and Settings close operations.

The service-boundary regression coverage now includes late Account A generation after logout/switch, direct stored-credential account changes, stale query ownership, same-account provider rerenders, legacy-token migration, one-time 401 rotation, and API completion after an account transition. I retained the hook-level late-publication test because it protects a separate LocalState invariant; it is no longer the only account-transition coverage.

Cleanup assessment

I agreed with and applied the focused simplifications:

  • inlined and removed accountMenuPresentation.ts and the obsolete single-child AccountMenu wrapper;
  • removed the Tailwind-token-only CreditUsage test and made its internal view private again;
  • removed test-only Settings billing re-exports/aliases while keeping the named Settings-entry refresh policy boundary;
  • removed the redundant Sidebar ref alias and one-argument cn() calls.

I did not apply the blanket removal of the Settings/chat-history presentation seams, native safe-area/source assertions, refresh lifecycle coverage, or explanatory comments. Those seams encode the two device-reported Mobile WebKit sticky-hover regressions while preserving desktop hover behavior, and the source assertions cover explicit acceptance criteria for which this repo has no native browser-layout harness. The comments document non-obvious account, WebKit, nested-query, and safe-area invariants; I found no no-comments requirement in the task.

Validation

  • Canonical frontend CI: 758 tests passed, 2,354 assertions, formatting and typecheck passed, lint had 0 errors / 13 pre-existing warnings.
  • Canonical PR web build passed.
  • git diff --check passed.
  • Independent follow-up reviews of the billing fence, Usage lock, and selective cleanup found no remaining blocker.

The local authenticated integration stack was unavailable in this workspace, so the new account-switch evidence is deterministic real-service-boundary test coverage rather than a live billing-environment smoke.

@marksftw

Copy link
Copy Markdown
Contributor Author

Safari QA UI automation report

Tested the authenticated Cloudflare branch preview in Safari against head b196f66af2ee764ca57d27b4b55fd976fa6d76f3.

Verdict

P1 / high-severity release blocker remains: compact project-chat history can still desynchronize the visible UI, URL, browser history, and reload state.

Reproduction

  1. Open / in a fresh compact Safari tab.
  2. Expand a project and select a chat in that project.
  3. Click Maple's in-app Back button.

Observed:

  • The main menu becomes visible, but the address remains /?conversation_id=<project-chat-id>.
  • Safari Back can leave the app for the prior browser page instead of returning to a canonical Maple root entry.
  • Safari Forward restores the conversation URL while leaving the menu visible.
  • Reload then opens the chat.

I reproduced this twice from a fresh compact root. The behavior is state-dependent: after navigating through project detail first, the same Back action synchronized correctly once, which may mask the regression during longer sessions.

Control case: the non-project “Twenty Minute Short Story” history entry synchronized correctly across in-app Back, URL, reload, and Safari Back/Forward.

Severity: I classify this as P1 rather than strict P0 because it breaks a core navigation invariant and should block release/merge, but I found no data loss, security exposure, or service-wide outage.

A likely mechanism is competing URL ownership between the mobile stack and the mounted chat:

This can plausibly leave a history entry whose mobile snapshot says “menu” while its href still names the conversation. The exact timing should be verified with an instrumented component test.

Passing UI coverage

  • Compact Settings gear opens /settings; wide Settings opens /settings/account.
  • Wide direct /settings redirects to /settings/account; compact direct /settings remains the menu.
  • Verified Account, Preferences, Security, Billing, API & credits, History, About, Usage, Delete account, API keys, and new-key routes.
  • Safari redirects passed:
    • /settings/api/proxy/settings/api
    • /settings/agent-connections/settings/account
  • Settings in-app Back, Escape, direct-load refresh, and browser Back/Forward stayed URL-authoritative.
  • Usage is absent from the home menu/sidebar, present below Log out in Settings, and routes to /pricing.
  • Compact detail header stayed pinned while the detail scroller moved.
  • Detail URL and approximate scroll position survived compact/wide resize.
  • Short-landscape Settings categories scrolled independently while account, Log out, and Usage remained pinned.
  • Light and dark appearance rendered correctly; the original System setting was restored.
  • No leaked initial focus outline; deliberate keyboard Tab focus remained visible.
  • Create/Rename Project, Rename Chat, Custom Instructions, and destructive confirmation dialogs were correctly positioned in compact and short landscape.
  • CRUD/backend smoke passed: create/rename project, create/rename chat, pin/unpin, successful chat response, and custom-instructions set/update/clear.
  • Billing/plan content remained visible while moving among Settings details; exact network refresh counts were not inspected.

Automated evidence and gap

frontend/src/utils/mobileNavigation.test.ts passes: 29 tests, 0 failures, 50 expectations.

Those tests cover pure stack and ownership helpers but do not mount MobileNavigationStack with UnifiedChat or exercise:

fresh compact menu → expanded project chat → in-app Back → Safari Back/Forward → reload

An integration/component regression test for that sequence is recommended.

Test state and boundaries

Created test data remains in the authenticated account:

  • Project: QA PR786 Renamed
  • Chat: QA PR786 Chat Renamed

The chat is unpinned and project Custom Instructions were cleared. Permanent deletion was not submitted.

Not validated in ordinary desktop Safari: native-iOS edge swipe, safe-area behavior, keyboard-adjusted VisualViewport dialogs, physical-device accessibility, cross-account billing-cache fencing, and exact 767/768px boundary pixels. No repository files were changed.

@marksftw

Copy link
Copy Markdown
Contributor Author

QA automation follow-up

I agree with the P1 / release-blocking classification. The proposed mechanism was correct, and tracing it exposed the exact timing:

  • TanStack router.history.push() updates router state immediately but buffers the native pushState until a microtask.
  • Fresh project-chat selection updates shared project state first, so React can mount the destination chat before that buffered push commits.
  • The mounted UnifiedChat layout effect could then replaceState the still-current root entry with the conversation href.
  • The buffered push subsequently created the real chat entry, leaving the previous entry with a menu snapshot but a chat href.

That also explains the state dependence: the extra project-state update widens the race, while arriving through project detail can make that update a no-op.

Fixed in 5b79e27:

  • MobileNavigationStack now flushes its pushed browser entry before publishing/rendering the destination snapshot.
  • Stack-managed UnifiedChat instances no longer independently canonicalize conversation hrefs; the compact stack is the single href owner. Draft-runtime state attachment remains intact.
  • Chat runtime ownership now requires the active/incoming stack page to match the live root URL and pathname. During the URL-first Back interval, a stale chat therefore fails closed before the stack pop update commits.
  • Added a deterministic deferred-history regression proving the root/menu and project-chat entries stay distinct when destination content performs a current-entry replacement, plus current/incoming URL-mismatch, New Chat, and non-home-path coverage.

One nuance: after Maple has genuinely returned to a valid canonical root entry, the next Safari Back leaving Maple for the prior site is expected browser behavior. The defect was that the Maple root entry itself had been rewritten, which made Forward and reload disagree with the visible menu.

Validation on the final diff:

  • Canonical frontend CI: 761 tests passed, 0 failed, 2,372 expectations; formatting and typecheck passed; lint had 0 errors / 13 existing warnings.
  • Focused mobile-navigation and chat-runtime suite: 44 passed, 0 failed.
  • Canonical PR web build passed (tree hash 22d6eda670eed2459870e0182f3613bc340e84a72dbf72a6d0275745cf822ab1).
  • Independent implementation and edge-case reviews found no remaining code blocker.

The new regression is focused causal coverage, not a Safari UI E2E. My browser session reached Cloudflare Access rather than the authenticated preview, so I am not claiming a fresh deployed Safari replay. Please rerun the exact fresh compact project-chat → in-app Back → Safari Back/Forward → reload sequence once Cloudflare finishes deploying head 5b79e27.

Comment on lines +164 to +167
JSON.stringify({
accountId: scope.accountId,
token: token.token
} satisfies StoredBillingCredential)

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.

Confirmed: this finding concerns a pre-existing storage design, not clear-text persistence introduced by this PR.

The current origin/master already stores the billing bearer token directly in sessionStorage:

sessionStorage.setItem(TOKEN_STORAGE_KEY, token.token);

git log -S traces that storage behavior to the initial commit (a8df346, January 27, 2025). This branch changes the stored value to an account-owned JSON record so a token cannot be reused across an account transition, but it does not broaden the storage lifetime or access model.

The CodeQL finding is technically valid and should not be labeled a false positive. However, redesigning billing credential storage is outside the scope of this mobile Settings PR. It should be tracked and addressed separately against master—for example, by replacing browser persistence with an account-scoped in-memory credential or another appropriately protected credential mechanism. No storage-architecture change will be made in this PR.

@marksftw
marksftw marked this pull request as ready for review August 15, 2026 23:42
@marksftw

Copy link
Copy Markdown
Contributor Author

closing because it grew too much in scope and the downstream PR got closed

@marksftw marksftw closed this Aug 16, 2026
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.

Polish mobile Settings and move Usage into Settings

2 participants