AI-native mobile UX: the primary surface isn't the browser
Here is an uncomfortable exercise. Open your AI product on a phone. Try to discover what it can do. Try to open whatever your product calls a workspace, an artifact, a canvas. Try to dismiss it.
If discovery requires Cmd+K, your capabilities are invisible on iOS and Android — there is no Cmd key. If actions appear on hover, they never appear at all, because touchscreens don't hover. If your panel scales in from a pane edge designed for a 1440px monitor, it produces a jarring layout collapse on a 390px screen. None of these are edge cases. For a conversational AI product, they describe the default experience for most of your users — because most of your users are on their phones.
We learned this building the HUMΛN Companion, and we ended up writing the lesson into Canon as a hard rule: the Companion is a mobile-first product, and every component has a mandatory mobile variant — not a responsive enhancement, but a distinct implementation for the mobile context. This post covers what that contract looks like in real code, and how we keep it from regressing.
Why mobile is the primary surface for an AI companion
A desktop dashboard is something you visit. A companion is something you carry.
The interaction model of an AI companion is fundamentally ambient: a question on the way to a meeting, an approval granted from a taxi, a briefing skimmed over coffee. The conversations that matter most — "approve this deploy," "what broke overnight," "escalate this to a human" — happen wherever the person happens to be, which is statistically not at a desk. If your AI product's mobile experience is a shrunken desktop layout, you haven't built a companion. You've built a desktop app with a hostile pocket mode.
Our Canon spec (kb/10, Rule 7 of the Companion platform rules) names the failure mode precisely: features that depend on keyboard shortcuts or hover states "silently disappear for the majority of users who access via phone." Silent disappearance is the worst kind of bug — nothing errors, nothing logs, the feature simply doesn't exist for most people.
The breakpoint contract: one number, one hook
The first decision was boring and load-bearing: viewport < 900px is mobile. One canonical breakpoint, defined once, consumed everywhere through a single hook. No component defines its own media query; no prop-drilling of viewport state through component trees.
This is the actual hook from @human/companion-chrome:
import { useState, useEffect } from 'react';
const MOBILE_BREAKPOINT = 900;
/**
* Returns true when the viewport is narrower than 900px (mobile / compact).
* Uses a ResizeObserver on the document root for precision.
* Safe for SSR — defaults to false during server render.
*/
export function useIsMobile(): boolean {
const [isMobile, setIsMobile] = useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return window.innerWidth < MOBILE_BREAKPOINT;
});
useEffect(() => {
if (typeof window === 'undefined') return;
const checkSize = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(checkSize);
observer.observe(document.documentElement);
return () => observer.disconnect();
} else {
window.addEventListener('resize', checkSize);
return () => window.removeEventListener('resize', checkSize);
}
}, []);
return isMobile;
}
Two details earn their keep. The lazy useState initializer checks typeof window === 'undefined' so the hook is SSR-safe — server renders default to the desktop layout and correct themselves on hydration rather than crashing. And it observes document.documentElement with a ResizeObserver instead of relying solely on resize events, which catches viewport changes that don't fire resize (browser UI chrome collapsing, split-screen on iPadOS).
Components consume it in one line:
const isMobile = useIsMobile();
That single boolean drives every translation that follows.
The component translations
Rule 7 is ultimately a table: for every desktop pattern, a named mobile counterpart. Not "make it smaller" — a different component shape.
| Component | Desktop | Mobile (< 900px) |
|---|---|---|
| Command palette | Floating overlay, Cmd+K or / first-char |
Bottom-sheet drawer, visible / toolbar button |
| Canvas animation | Scale from 0.95, from pane edge | Slide-up from y: '100%', fullscreen |
| Suggestion chips | Static flex row, X button per chip | Horizontal scroll strip, swipe-to-dismiss |
Kebab ··· menu |
Popover near trigger | Full-width bottom-sheet dialog |
| Hover-reveal actions | Shown on hover | Always visible |
| Canvas overlay | Side-by-side with conversation | Fullscreen |
Command palette → bottom sheet. On desktop, the palette is a floating overlay anchored near the input, opened by typing / or pressing Cmd+K. On mobile, both triggers are dead on arrival — so the palette becomes a bottom-sheet drawer pinned to the viewport bottom (borderRadius: '16px 16px 0 0', maxHeight: '80vh'), opened by an always-visible / icon button in the input toolbar. Same data, same filtering, same selection handler. Different physical object.
Suggestion chips → scroll strip. Desktop renders contextual chips as a wrapping flex row with a dismiss button per chip. On a 390px screen that wrap eats the input area, so mobile renders a horizontal scroll strip: flexWrap: 'nowrap', overflowX: 'auto', scrollSnapType: 'x mandatory', with WebkitOverflowScrolling: 'touch' for momentum. Chips dismiss by swipe instead of a fiddly X target.
Canvas → fullscreen with swipe-to-close. Desktop canvases live side-by-side with the conversation and scale in from the pane edge. On mobile there is no "side" — the canvas takes the whole screen and slides up from the bottom, and a downward swipe past 35% of the canvas height (CANVAS_SWIPE_CLOSE_THRESHOLD = 0.35) closes it. The gesture matters as much as the layout: the OS trained users that sheets dismiss downward, and fighting that training is a losing battle.
One physics system, two springs
Every canvas in the product opens with spring physics — that's its own Canon rule — but mobile gets its own spring constants, defined once in canvas-physics.ts:
// Desktop: scale-in from pane edge
export const CANVAS_SPRING = { stiffness: 280, damping: 26, mass: 0.8 } as const;
// Mobile: slightly stiffer for slide-up from bottom
export const CANVAS_MOBILE_SPRING = { stiffness: 320, damping: 30, mass: 0.9 } as const;
// Mobile canvas enter/exit — slide up from viewport bottom
export const CANVAS_MOBILE_ENTER = {
initial: { y: '100%' },
animate: { y: 0 },
exit: { y: '100%' },
} as const;
The mobile spring is deliberately stiffer and more damped. A full-screen sheet traveling the entire viewport height with the loose desktop spring overshoots and wobbles; the tighter CANVAS_MOBILE_SPRING arrives with conviction. A useCanvasAnimation(isOpen, isMobile) helper returns the correct framer-motion props for either context, so no component ever inlines spring values — if one canvas used a CSS transition and another used springs, the "living canvas" quality would fracture.
Safe areas: the pixels iOS reserves for itself
A bottom sheet pinned to bottom: 0 has a problem on any modern iPhone: the home indicator. iOS reserves a strip at the bottom of the screen for the swipe-up gesture bar, and content placed there is both visually occluded and functionally booby-trapped — a tap near your sheet's bottom row can trigger the system gesture instead. The fix is the env() safe-area variables:
.bottom-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
border-radius: 16px 16px 0 0;
/* Pad above the iOS home indicator so the last row
stays tappable and visible */
padding-bottom: calc(12px + env(safe-area-inset-bottom));
}
env(safe-area-inset-bottom) resolves to the height of the home-indicator zone on devices that have one (roughly 34px on recent iPhones) and to 0px everywhere else — so the same stylesheet works on Android and on older hardware without branching. Skip it and your palette's last command sits underneath the gesture bar: visible-ish, tappable-not. One more prerequisite: your viewport meta tag needs viewport-fit=cover, or the env() variables resolve to zero and you'll swear the technique doesn't work.
While you're in the stylesheet: 44px touch targets. Every interactive element in the Companion carries min-height: 44px; min-width: 44px, the Apple HIG / WCAG 2.5.5 floor. The close button on the mobile palette sheet renders a 20px glyph inside a 44px hit area — the visual can be small; the target cannot.
Testing the contract in CI
A UX contract that isn't tested is a UX suggestion. Our Playwright config runs a dedicated mobile project alongside desktop Chromium:
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
// Plan 05 mobile-first contract — iPhone 15 viewport for @plan05 tests
{ name: 'mobile-chromium', use: { ...devices['iPhone 15'] } },
],
The mobile-chromium project gives every test a real 390×844 viewport with touch enabled and a mobile user agent. The @plan05-tagged suite asserts the contract directly: the command palette renders as a bottom sheet (not a floating overlay), suggestion chips are horizontally scrollable, canvases open fullscreen, and — the unglamorous one — interactive elements measure at least 44px tall when the test computes their bounding boxes on the mobile viewport. That last assertion has caught more regressions than any other: it's trivially easy to ship a 32px button that looks fine in a desktop browser and is miserable under a thumb.
Because the mobile project runs in the same CI pipeline as desktop, a PR that breaks the bottom-sheet layout fails before merge. Nobody has to remember to check the phone.
The takeaway
If you're building an AI product, the mobile experience is not a port. It is the primary surface, and it deserves a contract: one breakpoint behind one hook, named mobile variants for every desktop pattern, springs tuned for full-screen travel, safe-area padding under every bottom-anchored sheet, 44px targets everywhere, and a CI project that runs the whole suite on a phone-sized viewport.
None of it is exotic. All of it is the difference between an AI companion people reach for on the move and a desktop demo they forget exists once they stand up.
This is Part 3 of the Companion Platform Series. Part 1: The Companion is the new AI UX. Part 2: Building discoverable AI capabilities.
Companion Platform Series — Part 3 of 3