Skip to content

feat(mobile): purpose-built phone view with a radial launcher#62

Draft
AllTerrainDeveloper wants to merge 1 commit intotrunkfrom
feat/mobile-view-desktop-mode
Draft

feat(mobile): purpose-built phone view with a radial launcher#62
AllTerrainDeveloper wants to merge 1 commit intotrunkfrom
feat/mobile-view-desktop-mode

Conversation

@AllTerrainDeveloper
Copy link
Copy Markdown
Collaborator

@AllTerrainDeveloper AllTerrainDeveloper commented May 1, 2026

Ship a real mobile experience for Desktop Mode. Auto-detects narrow
viewports, swaps the chrome for a touch-shaped policy, and replaces
the dock + title bar with a single floating radial launcher that
covers navigation, app-switching, and per-window actions.

Mobile.View.mov

Closes #55

Why

The desktop metaphor breaks at phone widths: dragging windows is
useless on touch, the dock crowds the viewport, and the 56 px title
bar plus tabs strip eats every spare pixel of admin content. Mobile
mode is a separate UX rather than a degraded desktop — opinionated,
gesture-first, and reachable with one thumb.

What ships

Detection

  • Single ResizeObserver probe in src/mobile/index.ts resolves
    'desktop' | 'tablet' | 'mobile' from viewport width. Stamps the
    result on <html data-wp-desktop-mode="…"> so CSS rules cascade
    off the attribute and JS reads it live (no module-level lag).
  • Server seeds the initial mode from wp_is_mobile() so phones don't
    flash desktop chrome before the first JS tick.
  • Public API:
    • wp.desktop.mode() — synchronous accessor.
    • wp.desktop.responsive.subscribe(fn) — fires on every flip.
    • wp.desktop.responsive.override('mobile' | null) — in-memory pin
      for testing / power-user "always desktop" preference.
  • New RESPONSIVE_MODE_CHANGED action (and matching
    wp-desktop-mode-changed CustomEvent) for plugins on the bus.

Window policy

In mobile mode every window is full-bleed and uneditable:

  • Title bar removed entirely. Tabs strip removed too — the
    radial owns submenu navigation.
  • Force-maximize on every open via a WINDOW_OPENED subscriber
    and a CSS pin (belt-and-suspenders against plugin geometry writes).
  • Drag and resize vetoed through two new filters,
    wp-desktop.window.drag-allowed and …resize-allowed. Plugins
    can layer additional vetoes (e.g. while a modal is showing).
  • Resize handles hidden so even a mouse can't initiate a drag.
  • Iframes auto-scroll to the top on IFRAME_READY so a fresh page
    always lands at the top — not wherever Core's last save banner /
    scroll-restoration left it.

Radial launcher (the centrepiece)

A single floating + button at the bottom centre of the viewport
expands into an arc of icons above it. Replaces the dock, the
bottom thumbnail switcher, and the in-window tab strip in one
component.

State machine

  • Peeking — only ~25 % of the FAB pokes above the viewport
    edge, with a dashicons-arrow-up-alt2 chevron at the very top
    and a gentle accent-coloured pulse so it's discoverable without
    being noisy. Tap → reveals + opens in one motion.
  • Open — fan of tiles in an arc, scroll-hint arrow pinned at
    the apex, action chips fan out at the upper corners.
  • Auto-retract — 3 s of no interaction collapses the radial
    back to peeking. Every interaction resets the timer:
    pointerdown / move / up on the gesture pad, taps on tiles,
    taps on action chips, taps on the preview thumb, FAB taps,
    drill in / drill back.

Tile presentation

  • 64 × 64 round tiles in dark glass with a 1 px hairline border.
  • Centred dashicon glyph at 22 px (driven through new shadow-DOM
    CSS vars --wpd-btn-size / --wpd-btn-icon-size so the same
    style scales the embedded <wpd-window-button>).
  • Curved label hugging the inner bottom arc via inline SVG
    <defs><path> + <text><textPath>. 11 px font with a 2.5 px
    dark stroke (paint-order: stroke) so labels stay legible if
    they graze the icon. Labels truncate at 13 chars with an
    ellipsis — no textLength/lengthAdjust distortion.

Layout & gestures

  • 46° angular step inside the visible cone (~95° half-arc).
  • Off-arc items compress into a tight stack at the edges with
    fading opacity — natural "more behind here" affordance, no
    separate UI element.
  • Snap-to-centre after every drag-release: the item nearest the
    apex animates into the exact 0° slot.
  • Drag from anywhere inside the radial — invisible 420 × 240
    px gesture pad anchored on the FAB centre. Pointer events bubble
    to the radial root which feeds the unified gesture handler.
    Movement threshold of 8 px discriminates tap from drag, so a
    quick tap on a tile activates it cleanly.
  • Mouse + touch + pen via one pointer-event code path.

Animations

  • Diff-driven render keyed by node id: rotation re-renders reuse
    existing tile DOM, so a continuous drag doesn't churn nodes or
    flicker transitions.
  • Scene-change animation — drilling into a submenu animates
    the parent set out (scale(0.4) + opacity 0) and the children
    in (scale(0.4) → 1, opacity fade) via a CSS keyframe.
    Visibility is driven by a --wpdm-radial-visibility CSS var so
    the open/leaving classes can override cleanly (an inline
    opacity would have beaten any class rule).
  • Tile fade-in / fade-out fully animated; rotation re-renders skip
    the keyframe so they don't restart on every pointermove.

Content

The root level merges, in priority order:

  1. Open windows — every currently-open Window instance,
    tinted accent-colour to read as "switch to" rather than "open".
    Tapping uses manager.focus() directly so identity is
    preserved even when multiple windows share a URL.
  2. Native shell windows — OS Settings, Code Editor, Recycle
    Bin, plugin-registered native windows from
    desktop_mode_register_window(). OS Settings has a sentinel
    route through wp.desktop.openOsSettings() because it isn't
    in the public registry.
  3. Server-registered desktop icons — wallpaper shortcuts via
    wp_register_desktop_icon(). De-duplicated against ci: make npm run test:php actually work #2 when
    they target the same native id.
  4. Every admin-menu top-level item — Dashboard, Posts, Pages,
    Plugins, Users, Settings, every CPT and plugin-contributed
    page. Drilling into one fans out its submenu items, with the
    parent itself re-prepended as a leaf so the user can still
    reach the parent landing page.

Submenu tiles inherit their parent's icon — drilled into Posts,
every fan-out tile carries the Posts dashicon to reinforce "still
in Posts" rather than displaying a generic chevron that conveys
nothing.

The home grid on the wallpaper does the same merge: a new
desktop_mode_desktop_icons JS filter at the top of
renderDesktopIcons() injects every dock item as a synthetic
icon when [data-wp-desktop-mode="mobile"] is set on <html>.
A repaintDesktopIcons() helper synchronously replays the last
renderDesktopIcons call (with the fingerprint cache busted) so
mode flips take effect immediately — no REST round-trip.

Smart preview thumbnail

When the snapped apex item matches an open window, a translucent
glass card floats in the middle of the screen:

  • One match — a single big card with icon, title and
    "Tap to switch". One tap focuses the existing window.
  • Multiple matches — header + horizontally scrollable strip of
    pills, one per window, with the focused window's pill tinted
    accent. Pick exactly which instance to bring forward.
  • Tapping the icon itself respects the dock item's multi
    flag: list-screen URLs (Posts, Pages, Media, Users) stack a new
    instance via openNew(), singleton URLs (Settings, Dashboard)
    focus the existing one. The "Tap to switch" thumb is the
    dedicated affordance for focusing regardless of multi.

Window action chips

Reload + Close fan out at the upper-left and upper-right
corners of the radial when it's open AND a window is focused.
Title bars are gone in mobile mode — these are the user's only
path to those actions, deliberately reachable in the same thumb
zone as the FAB. Close tints red on press as the danger telegraph.
Hidden when no window is focused (nothing to act on) via a
--has-focus root class kept in sync with WindowManager hooks.

CSS and chrome adjustments

  • Admin bar pinned to 40 px on mobile (Core's narrow-viewport
    stylesheet inflates it to 46 px+ otherwise) with row items
    pinned to match. The "Howdy, admin" / username badges hide so
    the bar stays single-row.
  • Body / #wpbody / #wpbody-content / #wpcontent padding +
    margin zeroed on the parent shell, and #wpbody { padding-top: 0 }
    added to chromeless mode (Core's @media (max-width:600px) rule
    was leaving 46 px of dead space at the top of every iframed page).
  • Dock and widget rail hidden entirely on mobile — the wallpaper
    grid replaces them.

Hooks added

PHP filters:

Filter Default Purpose
desktop_mode_mode_type wp_is_mobile() ? 'mobile' : 'desktop' Server-side initial mode guess (eliminates first-paint flash).
desktop_mode_responsive_breakpoints { mobile: 640, tablet: 1024 } Retune cutoff widths.

JS filters / actions (via wp.desktop.hooks):

Hook Type Notes
RESPONSIVE_MODE_CHANGED action { from, to, viewport } on every flip.
WINDOW_DRAG_ALLOWED filter Default true; mobile returns false.
WINDOW_RESIZE_ALLOWED filter Same shape.
desktop_mode_responsive_resolve filter Override the resolved mode regardless of viewport.
desktop_mode_desktop_icons filter Inject / reorder the wallpaper icon list.
desktop_mode_mobile_app_switcher filter Reshape the radial node list at any drill level.

Tests + verification

  • tests/vitest/mobile.test.ts — covers resolveMode, breakpoint
    classification, override behavior, plugin filter override,
    subscribe API.
  • npm run lint, tsc --noEmit, npm run test:js — all green
    (746 / 746).
  • npm run build — desktop bundle clean (~205 KB gzipped).

Files

Added

  • src/mobile/index.ts — detection, force-maximize, drag/resize
    filter subscribers, scroll-to-top, mode flips.
  • src/mobile/radial.ts — RadialLauncher class (FAB, gesture pad,
    arc, tiles, snap, preview, action chips, state machine).
  • assets/css/mobile.css — every [data-wp-desktop-mode="mobile"]
    rule.
  • tests/vitest/mobile.test.ts
  • docs/examples/mobile-mode.md

Modified

  • src/hooks.ts — three new HOOKS constants.
  • src/types.tsDesktopMode, responsiveBreakpoints,
    initialMode on DesktopConfig.
  • src/desktop.ts — boot bootMobile; expose mode() /
    responsive on the public API.
  • src/window/pointer.ts — drag/resize filter gates with attribute-
    driven mode lookup (avoids import cycle).
  • src/desktop-icons.tsdesktop_mode_desktop_icons filter,
    repaintDesktopIcons() / resetDesktopIconsFingerprint()
    helpers.
  • src/ui/components/wpd-window-button/wpd-window-button.styles.ts
    shadow-DOM CSS vars --wpd-btn-size, --wpd-btn-icon-size.
  • src/public-api.ts / src/global.d.ts — surface DesktopMode.
  • assets/css/chromeless.css#wpbody padding zero.
  • includes/render.php — payload extends with
    responsiveBreakpoints + initialMode; enqueue mobile CSS.
  • includes/assets.php — register wp-desktop-mobile style.
  • docs/architecture.md, docs/javascript-reference.md,
    docs/hooks-reference.md, docs/examples/README.md — every
    surface documented.

Out of scope

  • Tablet-specific UI — 'tablet' is detected but currently
    behaves like desktop. Reserved for a future hybrid pass
    (split view, slide-over, horizontal bottom dock).
  • Live or snapshot thumbnails inside the preview pills.
  • Pull-to-refresh, edge-swipe gestures, swipe-to-close on tiles.
  • Persisted "always desktop on this device" toggle (the in-memory
    override is the testing affordance only).
Open WordPress Playground Preview

- Implement tests for resolveMode() to classify viewports as desktop, tablet, or mobile based on width.
- Test override functionality to ensure mode can be pinned regardless of viewport size.
- Include tests for plugin hook integration via desktop_mode_responsive_resolve.
- Verify subscription mechanism returns an unsubscribe function.
- Ensure getMode() returns the cached current mode correctly.
@AllTerrainDeveloper AllTerrainDeveloper marked this pull request as draft May 1, 2026 14:56
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.

Investigate a mobile view

1 participant