Skip to content

Mobile Friendly Techniques

Ed Mozley edited this page Jul 15, 2026 · 9 revisions

Mobile‑Friendly: Techniques & Tricks

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.


Contents

  1. Wrap, don't edit β€” extending a page's JS from outside
  2. ⭐ Independent horizontal scroll per message (Shadow DOM)
  3. The iOS "reflow to desktop" trap β€” the meta‑lesson
  4. Section sheets β€” crowded panels get their own screen
  5. The sticky day‑heading (and its peek‑through trap)
  6. Master‑detail + the device back button
  7. Reflow & polish patterns
  8. Verifying without a device β€” headless rendering

1. Wrap, don't edit

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.js edit in the whole effort was a single TinyMCE content_style string β€” CSS genuinely can't reach inside the editor's iframe β€” and even that is gated on @media (pointer: coarse).


2. ⭐ Independent horizontal scroll per message

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-y stays hidden (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).


3. The iOS "reflow to desktop" trap

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: 768px media 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.


4. Section sheets

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.


5. The sticky day‑heading

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.


6. Master‑detail + the device back button

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');
});

7. Reflow & polish patterns

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;
}

8. Verifying without a device

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 equal

Prove 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-pane class 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.


Reference

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)

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally