Skip to content

feat(mobile): responsive UI overhaul and PWA shell#15

Open
AshDevFr wants to merge 13 commits into
mainfrom
mobile-support
Open

feat(mobile): responsive UI overhaul and PWA shell#15
AshDevFr wants to merge 13 commits into
mainfrom
mobile-support

Conversation

@AshDevFr
Copy link
Copy Markdown
Owner

Summary

Makes the Codex frontend usable on a phone (390px viewport) and ships a PWA shell so it installs to the home screen. Frontend-only; no backend or schema changes. Full plan and progress log: tmp/implementation/planned/mobile-support.md. Source audit: tmp/impl/mobile-audit.md.

Six phases delivered in order:

Phase 1: Foundation

  • New xs breakpoint at 482px in theme.ts (Mantine's default sm/md/lg/xl untouched).
  • Sidebar drawer auto-closes on navigation. Sidebar now accepts onNavigate?: () => void and AppLayout threads closeMobile from useDisclosure into it.
  • Global overflow-wrap: anywhere for .mantine-Title-root, .mantine-Breadcrumbs-root, .mantine-Anchor-root so long filenames stop pushing layouts past viewport.
  • Phone-only title scale via [data-order] CSS, plus a .touch-target opt-in utility class enforcing 44×44 min on touch elements.
  • Header burger bumped from sm (18px) to md (28px) with an aria-label reflecting drawer state.
  • Book Detail FILE row tightened to keep long paths from blowing out the layout.

Phase 2: Mobile search affordance

  • SearchInput migrated from visibleFrom="sm" to visibleFrom="xs"; tablet portrait keeps the desktop combobox.
  • New MobileSearchSheet renders a full-height Drawer position="top" with auto-focused input, Series/Books grouped results (max 5 each), loading and empty states, and "See all results" routing to /search?q=.
  • Header renders a search ActionIcon with hiddenFrom="xs" that opens the sheet.
  • New SearchResultItem module extracts SeriesResultContent and BookResultContent so the desktop combobox and the mobile sheet share rendering (~55 lines of duplicated JSX removed).

Phase 3: Reader mobile polish

  • Comic / EPUB / PDF reader audit on real iOS; findings documented inline in the plan.
  • ReaderToolbar collapses secondary actions (prev-book, next-book, fit-mode, page-layout) into an overflow Menu below xs; keeps close, title, page count, and primary settings visible.
  • New MobileReaderBottomBar slide-up control strip for prev / page-count / next / slider below xs.
  • Touch targets in the reader sized xl below xs; slider thumb increased for touch.
  • touch-action reviewed per fit mode so pinch-zoom is allowed where users want it (notably original).
  • Safe-area insets honored in reader overlays for later PWA standalone mode.

Phase 4: Tables to cards (ResponsiveTable + admin port)

  • New ResponsiveTable<T> component. Above xs: classic Mantine <Table> (existing styling and sort hooks preserved). Below xs: stacked label-value cards with row actions in a footer.
  • Ported: UsersSettings, PluginsSettings, ReleasesInbox (with filters moved into a drawer and a custom card layout), ServerSettings (settings-row variant), plus the long-tail settings pages (BooksInError, Cleanup, Duplicates, Integrations, Metrics, PdfCache, PluginStorage, ReleaseTracking, SeriesExports, SharingTags).
  • Bulk-select on ReleasesInbox handled per the plan's decision (see decisions section below).

Phase 5: Library page polish

  • Tab strip + right-side toolbar (view mode, sort, filter) reflowed below xs so tabs stay on one row.
  • Horizontal A-Z alphabet jump replaced with a "Jump to letter" picker on phones; the horizontal strip stays on tablet and up.

Phase 6: PWA shell

  • vite-plugin-pwa configured in vite.config.ts.
  • Hand-authored manifest.webmanifest with name, theme color, background color, display: standalone, scope, start_url, and a 192 / 512 / 192-maskable / 512-maskable icon set generated from codex-logo-color.svg.
  • iOS meta tags in index.html: apple-touch-icon, apple-mobile-web-app-capable, apple-mobile-web-app-status-bar-style, theme-color, viewport-fit=cover.
  • SW registered only under import.meta.env.PROD to avoid colliding with MSW's mockServiceWorker.js in dev. Update-available toast prompts a reload.
  • Cache strategy: NetworkFirst for /api/*, StaleWhileRevalidate for static assets, CacheFirst for icons/fonts. No write-request background sync in v1.
  • Subtle "Install to home screen" prompt that respects user dismissal, with an iOS-Safari manual-instructions fallback (no beforeinstallprompt on iOS).

Key decisions

  • xs = 482px, sm stays at 768px. Adds precision without retroactively changing every existing visibleFrom="sm" site. The visibleFrom/hiddenFrom audit (in the plan) showed only SearchInput needed migration; 8 other call sites stay on sm.
  • Mobile nav stays a burger drawer, not a bottom tab bar. Lower complexity, adequate for a reading-first app. Revisit after user feedback.
  • One shared ResponsiveTable rather than refactoring each admin page independently. Settings-row variant covers the bulk of admin layouts.
  • PWA shipped last so we don't install a broken-on-mobile app to the home screen.

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 16, 2026

Deploying codex with  Cloudflare Pages  Cloudflare Pages

Latest commit: 5552439
Status: ✅  Deploy successful!
Preview URL: https://5de21ab4.codex-asm.pages.dev
Branch Preview URL: https://mobile-support.codex-asm.pages.dev

View logs

AshDevFr added 9 commits May 16, 2026 14:27
…rflow fixes

Lays the groundwork for genuine mobile usability before tackling per-surface
polish in follow-up work.

- Add an `xs` breakpoint at 30.125em (~482px) to the Mantine theme as a
  phone-only line, while keeping `sm`/`md`/`lg`/`xl` at Mantine defaults so
  existing `visibleFrom="sm"` sites are unaffected.
- Auto-close the mobile sidebar drawer on navigation. `Sidebar` now accepts an
  `onNavigate` prop wired from `AppLayout`'s `closeMobile`; every navigable
  `NavLink` plus Logout calls it. The Settings parent toggle intentionally
  doesn't, so opening the submenu doesn't collapse the drawer.
- Global CSS for long-string overflow: `overflow-wrap: anywhere` on Title,
  Breadcrumbs, and Anchor surfaces, plus an override of Mantine's
  `white-space: nowrap` on breadcrumb items so long filenames wrap inside the
  row instead of pushing the layout wider than the viewport.
- Phone-only typography scale: Title `data-order` 1–3 shrink via overrides of
  Mantine's `--title-fz` CSS variable, avoiding 10-line wrapped headings on
  390px screens.
- Touch-target helpers: a `.touch-target` opt-in class enforces 44×44 below
  `xs`, and the Header `Burger` is bumped from `sm` to `md` with a
  state-reflecting `aria-label`. Mantine doesn't accept responsive object
  literals for `Title fz` or `ActionIcon size`, so the global CSS approach
  substitutes for the originally planned theme component defaults.
- Tighten the Book Detail FILE row (`flexShrink: 0` on label, `overflowWrap:
  anywhere` + `minWidth: 0` on value) so long filenames don't blow out the
  page on phones.

Tests added for the auto-close behavior covering positive cases (Home link,
Settings submenu link) and the negative case (Settings toggle).
Restores the ability to search from the mobile UI. Below 482px the
header renders a search ActionIcon that opens a top-anchored Drawer
with an auto-focused input and grouped Series/Books results. Selecting
a result navigates and closes the sheet; pressing Enter on a 2+ char
query routes to the full search page.

SearchInput's visibleFrom moves from sm to xs, so tablet portrait
(482-767px) keeps the inline combobox rather than degrading to icon
only. A new SearchResultItem module factors out SeriesResultContent
and BookResultContent so the desktop Combobox.Option rows and the
mobile UnstyledButton rows render identically and can't drift.

The mobile sheet intentionally does not nest a Mantine Combobox inside
the Drawer (portal and focus-trap conflicts), and arrow-key navigation
is not useful on touch.

Includes unit tests for the new sheet's open/close, query gating,
result selection, Enter routing, loading and empty states.
Below the xs breakpoint, ReaderToolbar collapses fit mode, page layout,
prev/next book, and fullscreen into a single overflow Menu, keeping only
close, title, settings, and the menu trigger in the top bar. Touch
targets bumped to size "xl" and toolbar padding now respects
env(safe-area-inset-*) for installed-PWA standalone mode.

Introduces MobileReaderBottomBar (mounted by ComicReader and PdfReader,
self-gated on the xs breakpoint) that restores prev/next/page-count/
slider as a sticky bottom strip. Page-count tap opens a jump-to-page
modal with a numeric input clamped to the page range, useful when the
slider thumb is too narrow for precise long jumps on a phone viewport.

EPUB reader surfaces its TOC, bookmarks, and search through a new
mobileMenuItems toolbar slot. The EPUB drawer bodies stay mounted on
mobile (display:none on their wrapper) so the overflow-menu items can
still toggle them via their portaled content.

ComicReader pinch-zoom posture now respects the active fit mode: the
"original" mode allows native pinch-zoom while scaling modes use
"manipulation" so the gesture does not fight the fit logic. PdfReader
always allows pinch-zoom since small PDF text commonly needs it. The
PdfReader search bar width is clamped to never overflow narrow
viewports.

Includes tests for the new bottom bar and the toolbar's mobile mode
(matchMedia override per test). Real-device iPhone verification is
deferred and tracked in the implementation plan's known limitations.
Introduce a shared <ResponsiveTable> primitive (web/src/components/ui)
that renders a Mantine Table above the xs breakpoint and a stack of
label/value Cards below it. Also export MOBILE_MEDIA_QUERY so callers
that don't fit the data-driven model can switch trees with useMediaQuery
at the same breakpoint.

Migrate every admin Table on the frontend so phones can manage settings
without horizontal clipping:

- Direct ResponsiveTable ports: UsersSettings, PluginStorageSettings,
  SharingTagsSettings, DuplicatesSettings (inner book table per group),
  SeriesExportsSettings, TasksSettings (both task list and by-type
  stats), and the ServerSettings change-history modal.
- Refactor ReleaseTrackingSettings: split the row component into pure
  SourceCell / PluginCell / LastPollCell / StatusCell and a stateful
  CronCell so the column-based model fits.
- useMediaQuery splits where colSpan-expandable rows or stateful inline
  editors don't fit the data-driven model: PluginsSettings, the two
  MetricsSettings tables (with Collapse-based details in the mobile
  cards), the ServerSettings per-category settings table, and the
  shared ReleasesTable (which also gives the Series Releases panel the
  same mobile treatment).

Includes tests for the new component. All existing settings/releases
tests still pass.
Make the library page usable on a 390px viewport — fix the two outstanding
audit findings without changing the desktop layout.

LibraryToolbar: below the shared MOBILE_MEDIA_QUERY breakpoint, switch from
a single `<Group justify="space-between">` row to a `<Stack>` with the tabs
on top and the page-size/sort/filter icon controls right-aligned underneath.
Keeps every control one tap away rather than hiding them behind an overflow
menu.

AlphabetFilter: below the same breakpoint, render a Mantine `<Select>` with
"All series", "#", and A–Z options (including counts and disabled state from
the alphabetical-groups endpoint) instead of the 28-button A–Z strip that
gets clipped off the right with no scroll indicator.

Tests cover both render modes; reuses the matchMedia override pattern from
ResponsiveTable and MobileReaderBottomBar so the breakpoint stays consistent.
…update prompts

Makes Codex installable to the home screen on iOS and Android with a proper
icon set, theme color, and standalone display mode, and caches the app shell
for fast cold loads in standalone mode.

- Add vite-plugin-pwa configured with generateSW; precaches the JS/CSS/HTML
  shell, NetworkFirst for /api/* with a 5s timeout and 5min TTL, CacheFirst
  for fonts/images with a 30-day TTL, navigateFallback wired to index.html
  with backend routes (/api, /opds, /komga, /docs, /health) on the denylist.
  clientsClaim: false + skipWaiting: false so updates only apply after the
  user confirms a reload.
- Hand-author web/public/manifest.webmanifest with name, theme_color matching
  the dark step of the primary palette, dark splash background, and 192/512
  any + 192/512 maskable icons (plus a 180px apple-touch-icon) generated
  from the existing logo SVG.
- Wire iOS / PWA meta tags into index.html: viewport-fit=cover, theme-color,
  manifest link, apple-touch-icon, apple-mobile-web-app-capable, and
  apple-mobile-web-app-status-bar-style=black-translucent so the reader can
  claim the notch in fullscreen mode.
- New PwaUpdatePrompt component using useRegisterSW from
  virtual:pwa-register/react; shows a sticky Mantine notification with
  Reload / Later actions when a new SW is waiting. Registration is mounted
  only under import.meta.env.PROD so MSW continues to own the dev SW slot.
- New InstallPrompt component handling Chromium/Android (beforeinstallprompt
  → Install button), iOS Safari (UA detection including the MacIntel-iPad
  fingerprint → "Show me how" modal with Safari Share-sheet instructions),
  and standalone/recently-dismissed (renders nothing). Dismissal persists
  for 30 days in localStorage. Includes unit tests covering all three paths.
- Add a @media (display-mode: standalone) rule in index.css that pads the
  AppShell header, navbar, and main content by env(safe-area-inset-*) so
  installed-mode chrome respects the iOS notch and home indicator. The
  reader's own toolbar and bottom bar keep their independent safe-area
  logic so fullscreen reading continues to reclaim the notch.
Close the gaps the post-merge mobile audit flagged in the EPUB and PDF
readers so touch users can actually reach the toolbar and read PDFs at a
legible default zoom.

- EPUB tap-to-toggle toolbar: wire useTouchNav onto the outer container
  and add a complementary rendition.on("click", ...) handler so taps
  inside the epub.js iframe (which never bubble across the iframe
  boundary) also toggle the toolbar. Skip clicks on links and form
  controls to avoid double-trigger. Outer-container swipes call
  rendition.next/prev directly.
- PDF mobile default zoom: read the mobile media query synchronously in
  the zoomLevel initializer (via { getInitialValueInEffect: false }) and
  default to fit-width on phones instead of fit-page, which previously
  squashed portrait PDFs to ~33% width.
- EPUB side arrows: react-reader renders the chevrons as inline-styled
  buttons with no class hook, so hide them via the existing
  readerStyles.arrow override when on mobile.

Tests cover the touch hook wiring, the rendition click handler (including
link-target suppression), the arrow display override on mobile vs
default, and the PDF zoom default flipping with the viewport.
Address the codebase-cleanup items the mobile audit surfaced:

- Delete web/src/App.css. The file was unimported anywhere in the
  workspace and consisted of leftover create-vite template rules.
- Strip the Vite starter defaults from web/src/index.css (the :root
  color/font block, a / a:hover, the template h1 / button rules, and
  the @media (prefers-color-scheme: light) overrides). Keep the body
  reset, the #root height rule, the focus-visible outline reset, and
  the PWA standalone safe-area + Mantine theming blocks that the app
  actually depends on.
- Add `?? []` guards inside ReleasesInbox's buildSeriesOptions /
  buildLibraryOptions / buildLanguageOptions so a facets response
  missing the series, libraries, or languages array no longer crashes
  the page on first paint. Export the helpers and unit-test the
  partial-response paths.
- Add web/src/mocks/handlers/releases.ts covering release-sources,
  releases, releases/facets, the per-series listing, and the per-row
  and bulk writes the inbox invokes, then wire it into the handler
  index. Mock-mode /releases now renders representative data instead
  of crashing on a missing handler.

Type-check, lint, and the full frontend test suite are clean; the
production build still passes.
- Header: add aria-label to the theme toggle so the three mobile-header
  buttons (burger, search, theme) all expose accessible names.
- GenreTagChips: thread an optional per-group badge variant through
  BadgeGroup and switch tags to variant="outline". The default
  "light + gray" combo resolves to a near-transparent surface in dark
  mode, which the audit flagged as "near-invisible". Outline keeps a
  visible border in both themes while preserving the GENRES-primary /
  TAGS-secondary hierarchy.
- PluginStatusBanner: stack the "View Plugins" anchor under the message
  below the xs breakpoint so it stops crowding the alert close button.
  Reuses MOBILE_MEDIA_QUERY so the gate stays aligned with the rest of
  the mobile responsive layer.
- HorizontalCarousel: hide the < > chevron group below xs and rely on
  native horizontal scroll + swipe. Single change covers every Home
  strip (Keep Reading, Recently Added Books/Series, Recommended, etc.).

Tests added for the new GenreTagChips variant behavior; full frontend
suite, type-check, lint, and build all clean.
AshDevFr added 4 commits May 16, 2026 17:17
…B iframe swipe)

Three real-iPhone bugs that the desktop and Chrome-emulation audit didn't
catch.

* Replace `100vh` with `100dvh` on reader root containers so the iOS
  Safari URL bar stops clipping the mobile bottom toolbar. Per-image
  `maxHeight: 100vh` in the continuous-scroll readers and the EPUB
  drawer-body `calc(100vh - ...)` heights are intentional large-viewport
  sizes and stay unchanged.

* Rework `useTouchNav` on Pointer Events so a single code path handles
  finger taps/swipes and mouse-drag in Chrome's mobile-viewport emulation
  without needing Sensors > Touch. Primary-pointer and pointer-cancel
  edge cases handled; public API is preserved so reader call sites are
  untouched.

* Bind tap/swipe pointer listeners inside the EPUB iframe via
  `rendition.hooks.content`, since touches that land on chapter text
  don't bubble out across the iframe boundary. Reading direction
  honours the OPF's `page-progression-direction` first, then the user
  setting. The previous `rendition.on("click")` becomes redundant and
  is removed.

Gesture classification is extracted into a shared `classifySwipe`
helper used by both the outer-container hook and the EPUB iframe
listener.

Tests added for the gesture classifier, the pointer-event variant of
`useTouchNav`, and the EPUB iframe pointer hook.
Address the remaining UX gaps from the mobile audit. Each change is small
and independent but they share a common theme: making mobile state more
discoverable and dismissals more durable.

- Add a one-time first-run hint shown in the reader on phones, teaching
  users that a center tap reveals the toolbar. Once per browser session
  via sessionStorage; auto-fades after a few seconds or on tap. Mounted
  by all three reader formats so the copy lives in one place.

- Add an EPUB chapter pill to the mobile reader bottom bar. EPUB
  pagination is reflowable, so the bar drops the slider for an EPUB
  layout (prev / "Ch N / total" / next) where tapping the pill opens
  the existing TOC drawer. The chapter index is derived by matching
  the current href against the top-level TOC.

- Add a bottom fade cue to the mobile sidebar when the navbar overflows
  (e.g. Settings expanded) and the user isn't scrolled to the bottom.
  Driven by a scroll listener plus a ResizeObserver so it updates as
  the Settings group toggles.

- Switch the plugin-failure banner from session-scoped dismissals to a
  localStorage map keyed by failureCount at dismissal time. The banner
  resurfaces when a plugin's failureCount exceeds the stored value,
  i.e. on a new failure rather than every reload.

- Add an OfflineBanner that listens to window online/offline events
  and renders a thin alert at the top of AppShell.Main when the
  browser reports the user is offline.

Tests added for each component.
Two handlers were firing on every tap: `useTouchNav.onTap` toggled the
toolbar globally while each reader surface also ran its own per-zone
React `onClick`. The net effect was that center taps double-toggled
(no visible change) and edge taps both navigated and flickered the
toolbar. Double-page mode had no center zone at all, so middle taps
navigated. TTB used horizontal zones, ignoring the reading axis.

Move zone classification into a single shared helper and let
`useTouchNav` own tap dispatch:

- `classifyTapZone(x, y, w, h, {readingDirection})` splits the surface
  into thirds along the reading axis. LTR/RTL use horizontal thirds
  (with RTL flipping prev/next polarity); TTB and webtoon use vertical
  thirds (top → prev, bottom → next). The middle third always returns
  `center`.
- `useTouchNav` calls `onPrevPage` / `onTap` / `onNextPage` based on
  the tap location, with a `tapZones: false` opt-out for surfaces
  that should treat every tap as a toolbar toggle.
- Drop the duplicate page-level `onClick` zone handlers from
  `ComicReaderPage`, `DoublePageSpread`, and `PdfReader`. The EPUB
  inside-iframe pointer handler now uses the same classifier against
  the iframe's viewport.

Continuous-scroll modes (CBZ continuous, webtoon, PDF continuous) keep
their existing behavior.

Added unit tests for `classifyTapZone`, zone-dispatch tests in
`useTouchNav` covering LTR/RTL/TTB and the `tapZones: false` opt-out,
and an EPUB iframe zone test. Stale per-zone tests on the page
components were removed since the behavior is centralized now.
Two real-iPhone regressions that desktop Chrome emulation didn't catch.

* touchAction "manipulation" (ComicReader) and "pan-x pan-y pinch-zoom"
  (PdfReader) let iOS WebKit claim horizontal pans for its own
  scroll/back-navigation gesture, firing pointercancel mid-swipe before
  our handler could classify it. Drop pan-x (keep pan-y for tall
  fit-width pages and pinch-zoom for detail) so horizontal swipes flow
  through to useTouchNav.

* ReaderToolbar and MobileReaderBottomBar pad themselves with
  safe-area-inset to clear the iOS notch and home indicator. In PWA
  standalone mode those insets push the bars well past the visible icons,
  and the bars' transparent gradient absorbed side taps the user
  intended for the page underneath. Set pointer-events: none on the
  outer Box and re-enable it on the actual controls so the gradient is
  purely visual.

* As a defensive fallback, treat pointercancel after a swipe-sized
  movement the same as pointerup so any remaining iOS gesture
  interception still navigates rather than silently discarding the
  swipe. Taps with no movement stay discarded since a canceled tap
  usually means the browser took the press for something else.

Tests added for the cancel-as-swipe behavior and the pointerup-after-
cancel no-double-fire case.
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.

1 participant