-
Notifications
You must be signed in to change notification settings - Fork 15
Mobile Friendly Techniques
A codeβlevel catalogue of the CSS and JavaScript techniques behind FreeITSM's mobile experience β the reusable moves, the nonβobvious ones, and the iOS traps that shaped them. If MobileβFriendly is the strategy and Mobile: Tickets is what was built, this page is how it works.
Everything here lives inside the one @media (max-width: 768px) block in assets/css/mobile.css (organised into numbered LAYERS) and the one matchMediaβgated IIFE in assets/js/mobile.js. Above 768px none of it exists, so desktop stays byteβidentical β that is the one hard rule.
- Wrap, don't edit β extending a page's JS from outside
- β Independent horizontal scroll per message (Shadow DOM)
- The iOS "reflow to desktop" trap β the metaβlesson
- Section sheets β crowded panels get their own screen
- The sticky dayβheading (and its peekβthrough trap)
- Masterβdetail + the device back button
- Reflow & polish patterns
- Verifying without a device β headless rendering
mobile.js never touches the 5,000βline inbox.js. It loads after it, and because both are classic (nonβmodule) scripts they share the global lexical environment β so mobile.js can read the page's state and wrap its functions from the outside.
Read the page's state by bare identifier (guarded, because it may not be a global on every page):
function getCurrentEmail() {
return (typeof currentEmail !== 'undefined') ? currentEmail : null;
}
// same trick for API_BASE, formatFullDateTime, ticketAttachments β¦Wrap a global to add a mobile branch, leaving the desktop path exactly as it was:
if (typeof window.showAuditHistory === 'function') {
var _orig = window.showAuditHistory;
window.showAuditHistory = function () {
if (mq.matches) { openAuditSheet(); return; } // phone: our own sheet
return _orig.apply(this, arguments); // desktop: untouched
};
}The same move wraps selectEmail / selectFolder (to drive the pane stack), renderAttachmentInfoBar (to refresh a compact badge), and syncPopoutToTicketState (to strip a desktopβonly body class on mobile). The renderer is never edited, so desktop can't regress.
The one justified
inbox.jsedit in the whole effort was a single TinyMCEcontent_stylestring β CSS genuinely can't reach inside the editor's iframe β and even that is gated on@media (pointer: coarse).
The problem. Email bodies are rendered inside a shadow root so a thirdβparty email's CSS displays exactly as its author intended, sealed off from the app. A marketing email built to a fixed 680px table is therefore wider than a phone. Desktop clips that overflow so it can't overlap the panels below; on a phone that clipping meant the rightβhand half of such an email simply vanished off the edge with no way to reach it.
The insight. Each message is its own shadow host in the light DOM. Overflow set on the host governs the shadow content β so you can turn each message into its own horizontal scroll container without touching the sealed email CSS.
// inbox.js β every message body is a shadow host
function hydrateEmailBodies(root) {
root.querySelectorAll('[data-email-body]').forEach(host => {
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = '<style>*{box-sizing:content-box}</style>' + bodyHtml;
});
}/* mobile.css β LAYER 12. The fix is on the light-DOM host, outside the shadow. */
.email-body-content,
.thread-message-body {
overflow-x: auto; /* desktop had overflow:hidden β clip β scroll */
-webkit-overflow-scrolling: touch; /* momentum scroll on iOS */
overscroll-behavior-x: contain; /* swipe doesn't chain to the page / back-nav */
}Why it's clean:
- The sealed CSS is untouched. The rule targets the host element, never anything inside the shadow tree.
-
Only that message scrolls sideways.
overflow-ystayshidden(inherited from the desktop rule) so vertical containment is preserved; the thread, the reading pane and the page never scroll horizontally. - Replies written in FreeITSM are unaffected β they already wrap to fit, so there's nothing to scroll.
The result is that different sections of one ticket thread scroll horizontally independently β a wide newsletter swipes sideways in its own box while the plainβtext reply beneath it sits still. See Β§8 for how this was proven (the page must not widen β document.scrollWidth === innerWidth).
This is the single most important thing to understand about mobile Safari here, because three separate bugs were all the same bug.
iOS sizes the layout viewport to fit its widest content. Anything wider than the screen makes Safari reflow the whole page to a desktop width β the
max-width: 768pxmedia query stops matching β every mobile rule switches off β the layout snaps back to the desktop one (a centred modal box, cramped columns). It looks like "mobile mode broke."
So the governing rule is: nothing in a mobile view may be wider than the screen. The trap sprung three ways:
| What was too wide | Symptom | Fix |
|---|---|---|
A form field with font-size < 16px
|
iOS zooms on focus β sheet spills wide β reflow |
16px on every focusable field (@media (pointer: coarse) for iframe editors) |
| The audit history's 5βcolumn table | Modal reverts to a centred desktop box | Don't render a wide table on a phone β render a narrow card feed in a sheet |
| A fixedβwidth external email | Rightβhand content unreachable | Contain it in a perβmessage scroll box (Β§2) |
The lesson that ties them together: on mobile you don't clip and hope β you either reflow the content to fit or give it its own bounded scroll container. A wide thing left loose in the layout doesn't just overflow, it detonates the whole media query.
Crowded readingβpane sections (Links, Properties, Time, Objects) each get their own fullβscreen sheet β a position: fixed; inset: 0 panel β opened from the action toolbar. It's configβdriven, so a section is one array entry:
var SECTIONS = [
{ cls:'links', title:'Links', icon:'π', sel:'.problem-strip', all:true },
{ cls:'props', title:'Properties', icon:'β', sel:'#ticketPropertiesContainer', all:false },
{ cls:'time', title:'Time', icon:'β±', sel:'#timeEntriesContainer', all:false },
{ cls:'cmdb', title:'Objects', icon:'π₯', sel:'#cmdbObjectsContainer', all:false }
];For each entry, mobile.js builds a sheet, relocates the section's DOM node into it (keeping its id so async loaders still find it), and adds a toolbar button. Because the reading pane reβrenders on every ticket open, the relocation reβruns after each render, idempotently.
Audit history is the exception that proves the pattern. It isn't in the reading pane to relocate, so instead of moving a node it fetches on demand and renders into the same sheet chrome:
function openAuditSheet() {
auditSheet.style.display = 'flex';
fetch(API_BASE + 'get_ticket_audit.php?ticket_id=' + email.ticket_id)
.then(r => r.json())
.then(d => renderAuditFeed(d.audit)); // day-grouped cards, never a table
}That single decision β feed, not table β is also what defuses the reflow trap for audit.
The audit feed groups entries by day with a sticky date bar. Two nonβobvious tricks made it behave.
The peekβthrough trap. A scroll container with padding-top makes position: sticky; top: 0 stick to the contentβbox top β i.e. below that padding β leaving a transparent strip under the header where scrolling rows show through above the stuck date. Fix: zero the scroll body's top padding so the date tucks flush, and let the date's own opaque padding be the only gap.
.mobile-sheet-audit .ms-body { padding-top: 0; } /* kill the peek-through strip */Fullβbleed shaded bar. The heading node is a <span>, so it needs display: block or the shade shrinkβwraps the text. To run edgeβtoβedge inside a padded container without widening it, cancel the container's side padding with equal negative margins, then reβinset the text with matching padding:
.ma-day {
display: block; /* a <span> won't fill width otherwise */
position: sticky; top: 0;
margin: 0 -16px; /* negative margin == .ms-body padding */
padding: 9px 16px 7px; /* re-inset the text to line up with rows */
background: var(--surface-hover);
border-bottom: 1px solid var(--border);
}Because the margins exactly cancel the padding, the bar reaches both edges and can't add horizontal overflow β which, per Β§3, matters more than it looks.
The desktop threeβpane split (folders / list / conversation) becomes a oneβpaneβatβaβtime slide stack. State is a single attribute on <body>, and the panes are slid with transform via CSS ancestor selectors:
body[data-mobile-pane="reading"] .email-list-container { transform: translateX(-100%); }
body[data-mobile-pane="reading"] .reading-pane { transform: translateX(0); }The move that makes it feel native rather than like a resized website: wire each inβpane navigation to history.pushState, and restore the pane on popstate β so the device back button pops the stack.
function pushPane(p) {
setPane(p);
if (mq.matches) history.pushState({ nmPane: p }, '');
}
window.addEventListener('popstate', function (e) {
if (mq.matches) setPane((e.state && e.state.nmPane) ? e.state.nmPane : 'list');
});Small, repeatable moves applied across the section sheets. Together they're the difference between "shrunk desktop" and "designed for a thumb."
Wrap a desktop singleβrow form. The timeβentry form (minutes + notes + Add) ran off the edge. flex-wrap plus a fullβwidth action row fixes it with no markup change:
.mobile-sheet-time .time-entry-form { flex-wrap: wrap; }
.mobile-sheet-time .time-entry-add-btn { flex-basis: 100%; } /* Add drops to its own row */Strip nestedβcard chrome. Panels built as cards (border + shadow + padding + margin) are redundant inside a sheet that's already a panel β flatten them so content sits flush:
.mobile-sheet-cmdb .cmdb-section { border: none; border-radius: 0; padding: 0; margin: 0; background: none; }Kill :hover transforms on touch. A liftβonβhover (translateY + shadow) sticks after a tap on a touchscreen and reads as jank. Remove it on the sheet:
.mobile-sheet-cmdb .cmdb-link-card:hover { transform: none; box-shadow: none; }Darkβmode remap, scoped to the sheet. Where a panel is authored in hardcoded light colours that glare in dark mode, remap them under [data-theme="dark"] β scoped to the sheet so desktop and light mode are untouched, and following the theming rule that a midβtone brand accent must be lifted to a lighter tint to stay legible on a dark ground:
[data-theme="dark"] .mobile-sheet-cmdb .cmdb-link-card { background: #271e25; border-color: #45293a; }
[data-theme="dark"] .mobile-sheet-cmdb .cmdb-section-head h3 { color: #f472b6; } /* lifted accent */One heading per sheet. Each sheet's own .ms-head title is the single heading; the relocated panel's inner heading is hidden so the title isn't said twice (a white "Links" over a purple "Links"):
.ms-body .problem-strip-label { display: none; } /* Links */
.mobile-sheet-cmdb .cmdb-section-head h3 { display: none; } /* Objects */Fingerβfriendly chrome. The corner β becomes a blue Close button; bare textβlink actions become pills; every tap target clears ~40px:
.ms-close {
min-height: 38px; padding: 8px 18px; border-radius: 8px;
background: var(--accent); color: var(--on-accent);
font-size: 15px; font-weight: 600; border: none;
}There's no headless browser in the normal loop and the inbox needs auth, so onβdevice testing with a human is the real proof. But a lot can be checked first by driving headless Chrome against the live CSS/JS on localhost.
Screenshot a sheet at phone width. Headless Chrome on Windows won't make a window narrower than ~500px, so a --window-size=390 screenshot is really a 500px viewport clipped to 390 β content looks like it overflows when it's wrapping fine. Two ways round it: pin the element to a phone width in the test markup, and measure innerWidth rather than trusting the clipped image.
// what a wide email must NOT do: widen the page
out.textContent = 'docScrollW=' + document.documentElement.scrollWidth +
' innerW=' + window.innerWidth; // these must be equalProve the Shadow DOM scroll containment for real. The horizontalβscroll fix was verified with an actual attachShadow host holding a 680px table: the host reported scrollWidth 721 > width 300 with overflow-x: auto (it scrolls inside), while document.scrollWidth === innerWidth (the page did not widen). A screenshot alone couldn't have shown that.
Harness gotcha: don't reuse the real
.reading-paneclass in a test wrapper β it inherits the app's positioning and faked a 720px document width, sending an early diagnosis completely wrong. Use a plain<div>with an explicit width.
Parseβcheck injected JS by loading mobile.js in a throwaway page with window.onerror writing to document.title, then dumping the DOM β a syntax error surfaces as an ERR: title without needing Node.
| Layer | Trick |
|---|---|
mobile.css LAYER 1β9 |
App shell, masterβdetail, modalβsheet, density, bottom bar, collapsible header, section sheets, openedβticket refinements, AskβAI panel β see Mobile: Tickets |
| LAYER 10 | Audit history sheet + dayβgrouped feed (Β§4, Β§5) |
| LAYER 11 | Timeβentries reflow (Β§7) |
| LAYER 12 | Independent horizontal scroll for wide email (Β§2) |
| LAYER 13 | Affectedβobjects (CMDB) polish + dark remap (Β§7) |
- CSS:
assets/css/mobile.cssβ one@media (max-width: 768px)block, LAYERS 1β13. - JS:
assets/js/mobile.jsβ onematchMediaβgated IIFE. - Related: MobileβFriendly (strategy & iOS lessons), Mobile: Tickets (what was built), Theming & Dark Mode.
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)