MobileTrigger — FAB + swipe entry for touch devices
Two mobile entry points: a floating action button (FAB) that's always visible, and a rapid up-down swipe gesture that slides in top + bottom chrome bars. Tap the central voice button, open the palette, or close to return to the page.
Opt-in. Mobile-only — no-op on wide desktop viewports unless forceEnable: true.
When to use
- Any production app shipping dddk to phones / tablets
- Demo pages where users will visit on mobile — the FAB is the only obvious entry point without a hardware keyboard
- Kiosks / tablets where users won't think to long-press or use Ctrl+K
Don't use when:
- Your app is desktop-only and you've set
viewportto disable mobile rendering - You already ship your own FAB / nav trigger — see
bindTriggerto wire that into dddk instead
Import
import { MobileTrigger } from '@perhapxin/dddk';
import type { MobileTriggerConfig, MobileFABConfig } from '@perhapxin/dddk';
Minimum setup
import { MobileTrigger } from '@perhapxin/dddk';
const mobile = new MobileTrigger();
mobile.attachTo(dddk);
The default FAB renders bottom-right with a sparkle SVG. Tap opens the palette; long-press fires voice_start (same as the desktop Space gesture).
Three ways to trigger
1. Default FAB
Always visible on touch devices or narrow viewports (max-width: 768px). Fully customisable:
new MobileTrigger({
fab: {
icon: '✨', // emoji, raw HTML/SVG, or an HTMLElement
text: 'Ask', // optional text next to the icon
position: 'bottom-left',
offset: { x: 16, y: 80 },
size: 60,
shape: 'pill', // 'circle' | 'square' | 'pill'
style: { background: 'linear-gradient(45deg, #ec4899, #8b5cf6)' },
onTap: (dddk) => dddk.palette.toggle(),
onLongPress: (dddk) => dddk.triggerVoiceStart(),
},
});
Disable the default FAB:
new MobileTrigger({ fab: false });
2. Bind your own trigger
If you already ship a header / nav button, give it the same gesture handling as the FAB:
const off = mobile.bindTrigger(document.querySelector('#my-header-btn')!, {
onTap: (dddk) => dddk.palette.toggle(),
onLongPress: (dddk) => dddk.triggerVoiceStart(),
});
// later: off();
Combine with fab: false so dddk doesn't render a second floating button.
3. Swipe gesture — top + bottom chrome
Rapid up-down finger wiggle (default ≥3 direction reversals within 700ms) slides in two semi-transparent chrome bars:
┌──────────────────────────────────────────┐
│ 🔍 Palette ✕ │ ← top bar
├──────────────────────────────────────────┤
│ │
│ (original page content — still │
│ scrollable; tapping any element │
│ triggers dwell-style │
│ instead of the element's own click) │
│ │
├──────────────────────────────────────────┤
│ ● (center button) │ ← bottom bar
└──────────────────────────────────────────┘
Why this gesture: normal scroll only goes one direction at a time → no false positives. Doesn't collide with iOS back, Android nav, or pull-to-refresh.
Center button (replaces desktop Space):
| Gesture | Event |
|---|---|
| Single tap | dddk:mobile-accept |
| Double tap (< 350ms) | dddk:mobile-reject |
| Long press (> 200ms) | dddk:mobile-voice-start then dddk:mobile-voice-end on release |
Host listens to these document events and wires them to the same handlers it uses for the desktop Space gesture.
Configuration
| Option | Default | Description |
|---|---|---|
reversalsToTrigger |
2 |
Min direction reversals in the swipe window to trigger chrome. |
swipeWindowMs |
900 |
Time window (ms) the reversals must happen within. |
minSwipeDelta |
18 |
Min Y delta per reversal (px). Filters jitter. |
longPressMs |
200 |
Long-press threshold for voice. |
doubleTapMs |
350 |
Double-tap window. |
forceEnable |
false |
Run on non-touch devices (for testing in DevTools). |
dwellOnTap |
false |
When chrome is open, taps on page elements fire palette with the tapped element as context. |
fab |
{} |
MobileFABConfig | false. Customise or disable the default FAB. |
FAB config (MobileFABConfig)
| Option | Default | Description |
|---|---|---|
enabled |
true |
Show the FAB. |
icon |
sparkle SVG | Emoji, raw HTML, or an HTMLElement. |
text |
— | Optional text next to the icon. |
ariaLabel |
'Open command palette' |
ARIA label. |
position |
'bottom-right' |
'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'. |
offset |
{ x: 20, y: 24 } |
Pixel offset from the edges. |
size |
56 |
Diameter (or height for pill). |
shape |
'circle' |
'circle' | 'square' | 'pill'. |
style |
— | Inline CSS overrides, applied LAST. |
className |
— | Extra className for host CSS targeting. |
onTap |
open palette | (dddk) => void. |
onLongPress |
voice start | (dddk) => void | null. Pass null to disable. |
Runtime API
mobile.show(); // slide in chrome bars
mobile.hide(); // slide out
mobile.toggle(); // toggle chrome
mobile.setDwellOnTap(true); // route page taps through Dwell while chrome is open
mobile.isDwellOnTap(); // boolean
mobile.setFabVisible(false); // hide / show the default FAB
mobile.getFabElement(); // the <button> if it's been mounted
mobile.destroy(); // remove FAB + chrome + listeners
Viewport detection
The module reacts to live viewport changes via matchMedia('(max-width: 768px)'). Rotating the device or resizing the window across the breakpoint mounts / unmounts the FAB automatically. The swipe detector is only bound on real touch devices ('ontouchstart' in window) — mouse drag never mis-triggers it.
The FAB is hidden on wide desktop viewports via a CSS @media rule, even if attachTo() mounted it (this matters when a single host runs both desktop and mobile in one bundle).
Tear-down
mobile.destroy();
Removes the FAB, both chrome bars, all event listeners, and the viewport media-query listener.
Touch-gesture API on the orchestrator
On desktop, Space is wired to a complete gesture pipeline — accept event for script skills, intent emission for analytics, subtitle invoke. Touch devices don't have Space, so the SDK exposes the same pipeline through two public orchestrator methods you call from a FAB tap / custom button:
dddk.triggerAccept(); // same as a single-tap Space
dddk.triggerReject(); // same as a double-tap Space
Both fire:
gesture_accept/gesture_rejectevent (script-skill runners wait on these),subtitle.invokeAccept()/invokeReject()(advances pause hints, accepts confirm dialogs, closes info bars),- An
intentevent withkind: 'agent_answered'(so analytics records the gesture).
The recommended MobileFABConfig.onTap is the dual-mode pattern below — accept-when-something-needs-it, otherwise open the palette:
new MobileTrigger({
fab: {
onTap: (dddk) => {
if (dddk.subtitle.isVisible()) {
dddk.triggerAccept(); // mid-task continuation
} else {
dddk.palette.toggle(); // idle: open palette
}
},
},
});
triggerReject() is symmetric — wire it to a "cancel" gesture (a double-tap on your own button, a hold-and-release, etc.).
Single-tap / double-tap routing on the subtitle bar itself is built in: on touch devices the bar listens for pointerup events and dispatches dddk:bar-tap-accept / dddk:bar-tap-reject DOM events, which the orchestrator picks up and routes through the same pipeline. No host wiring needed — script skills advance on a tap on the bar even without the FAB.
See also: ./voice.md for the voice events the center button + FAB long-press fire into, ./overview.md for the module index.