Skip to content

Mobile Friendly Techniques

Ed Mozley edited this page Aug 9, 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)
LAYER 14–15 Assets β€” two‑pane stack, card feeds, the shared .dt-* table view, dashboard, settings, servers β€” see Mobile: Assets
LAYER 16 Calendar β€” month pills β†’ dots + a tapped‑day agenda, week as a contained scroller, popup β†’ bottom sheet β€” see Mobile: Calendar
LAYER 17 Knowledge β€” search relocated into the sub‑bar, author‑written article HTML contained, the editor's localStorage pop‑out neutralised β€” see Mobile: Knowledge
LAYER 18 Service Status β€” two‑up service board, incidents as a card feed β€” see Mobile: Service Status. No mobile.js branch: not every module needs one.

Two later additions worth knowing about

  • ⚠️ A rule that "already covers this" is worth measuring. LAYER 3's 16px anti‑zoom rule (.modal-content .form-input, specificity 0,2,0) had never applied to the typed inputs, because inbox.css styles them as .modal .form-group input[type="date"] (0,3,1) at 14px β€” so every native date/time/number field and every <select> in every modal stayed at 14px on a phone, springing Β§3 on focus. Fixed in #999 by mirroring the inbox selector list exactly, so specificity ties and mobile.css wins on load order. Found by measuring computed styles, not by reading the CSS.

  • A JS‑positioned element can often be re‑positioned in pure CSS. Author !important beats an inline style, so the calendar's click‑positioned quick‑view popup became a bottom sheet with no JS wrap at all β€” top: auto !important; bottom: 0 !important; left: 0 !important; right: 0 !important. The knowledge Share dropdown took the same treatment.

  • ⚠️ A wide table that "fits" hasn't been fixed β€” it has been crushed. display: block; overflow-x: auto on a table is only half the rule; without white-space: nowrap on the cells the browser wraps every column down to one word per line, which passes the docScrollW === innerWidth check and is unreadable. Containment is the floor, not the finish line β€” the same lesson the Calendar settings page taught from the other direction.

  • ⚠️ The sticky peek‑through has now happened in two unrelated modules. Β§5 found it on the audit sheet; the knowledge article's sticky title hit it again for the identical reason β€” a sticky element sticks to the top of its scroll container's content box, i.e. below its padding-top, leaving a transparent strip that scrolling text slides through. If a module has a position: sticky header, check what padding its scroll container carries before looking anywhere else. Don't delete the space: move it onto something that scrolls away (a margin on the row above), and make any negative margin match the card's padding exactly β€” an over‑long one lifts the bar clear and opens a strip of its own.

  • A collapsible summary should BE the tap target. The knowledge article's meta block (author / created / modified / views) collapses to one line, and the whole row is the control rather than a chevron beside it: a much bigger target, and its accessible name is the visible text β€” so it needs no aria-label, and therefore no new string in 24 languages. Draw the chevron with borders, not a β–Ύ glyph; at 11px the character renders as a faint dot in the app's font stack.

  • ⚠️ When you "normalise" a layout, state BOTH insets in one place. Aligning a modal heading with its fields meant zeroing .modal-content's padding and giving the body 16px β€” and leaving the header to LAYER 3's plain .modal-header. That rule doesn't always win: inbox.css carries .modal-content:has(> .modal-body) > .modal-header at 24px, which is (0,3,0) and beats it. Only the modals that had a .modal-body matched, so three dialogs lined up and the fourth sat 8px further in β€” a near-miss that reads as a one-page quirk until you find the selector. Setting both insets in the same block is what makes "normalise" actually normalise.

  • ⚠️ Measure where the TEXT starts, not where the box starts. Comparing a heading's alignment with a field by getBoundingClientRect().left compares a full-width <div> against an inset <label> β€” it measures the padding, not the misalignment, and reported a consistent 14px error on four pages that were mostly fine. Add each element's own padding-left before comparing.

  • A shared class means a one-page fix is a five-page fix. .tab-content is styled once in inbox.css with padding: 30px β€” a sixth of a 360px screen. Trimming it for one module's settings screen improved all five, which is the payoff for keeping module rules keyed on shared classes rather than page ids.

  • ⚠️ Read a page's own CSS comments before opting it in. tickets/settings/ states outright that <body> must not be flex β€” a flex body turned extension‑injected nodes (LastPass) into flex items and wrecked the layout β€” and builds its own .settings-shell instead. LAYER 2 makes <body> a 100dvh flex column on every opted‑in page, so opting it in blind re‑creates a bug somebody already diagnosed. The escape hatch is a marker: body[data-mobile-shell="own"] { display: block; height: auto }. No harness would have caught this β€” a headless run has no browser extensions.

  • ⚠️ A page that renders one tab at a time is 1/N verified. Measuring tickets/settings/ as loaded reported clean; walking all 18 tabs and measuring each found under‑sized fields on two of them and crushed grids on another. Drive the tabs.

  • Exclude what must not change, don't enumerate what must. The settings anti‑zoom rule listed six input types and missed a datetime-local sitting at the browser default. Rewritten as input:not([type=checkbox]):not([type=radio])…, a newly added field type is covered by default instead of forgotten.

  • ⚠️ Stacking a grid to one column does nothing if its children grid-column: span 2. A child spanning two columns of a one‑column grid makes the browser create an implicit second column, so the layout stays two‑up and the rule looks like it simply failed. The tell is that the tracks come out unequal (measured 93px + 111px where 1fr 1fr would be even). Neutralise the spans too.

  • Ask the question measurement can answer. "Contained but crushed" kept slipping past overflow checks β€” a two‑column grid at 360px doesn't overflow, it just squeezes each column to 150px. Rather than keep relying on screenshots, assert it directly: nothing inside the container may still be laid out in more than one column at phone width.

  • ⚠️ position: sticky does not pin anything to the bottom of a SHORT container. Sticky only stops an element scrolling away β€” with less content than screen it just sits where the content ends, halfway up. Pair it with margin-top: auto in a flex column: auto-margin handles the short case, sticky the long one. ⚠️ And the two fight a negative margin-bottom β€” auto absorbs whatever free space the negative margin creates, so they cancel and the bar lands its own margin short of the edge (measured: 14px). Drop the container's bottom padding instead. margin-top: auto must also come after any margin shorthand.

  • ⚠️ Never mix getBoundingClientRect() with clientHeight/offsetTop on anything inside a transformed container. Rects are in scaled coordinates, layout properties are not β€” rect.top + el.clientHeight produced a "bottom" 11px beyond a 760px viewport and sent a diagnosis completely wrong. Compare rect to rect, or computed style to computed style, never one of each. (Modals that animate in with a transform make this a routine hazard: it has now caused four false alarms.)

  • ⚠️ An inline style.display set by the page's own JS beats your rule. Service Status does table.style.display = 'table' every time it renders incidents, so the card‑feed conversion needed display: block !important β€” without it the feed works until the first refresh and then silently reverts. Grep a module for style.display before converting one of its tables.

  • "It reflows" is not the same as "it works". The service board was already an auto-fill, minmax(200px, 1fr) grid β€” technically responsive, and at 360px a single column, i.e. a list. A status board exists to be scanned, so density beats card size: dropping the floor to 150px got two up and halved the scroll. Ask what the component is for before accepting that it already reflows.

  • Not every module needs a mobile.js branch. Four in a row had one, which made a fifth feel inevitable; Service Status has no sidebar, no pane stack and nothing worth wrapping, so the shared shell was the whole of its behaviour and the module shipped as CSS only.

  • ⚠️ A CSS comment edit is a code edit. Appending a paragraph to an existing comment and closing it early orphans text outside the block, and the parser then drops the rule that follows. It happened twice in one session; both times the brace‑balance and column‑0 checks passed happily and only the assertions caught it.

  • ⚠️ A JS‑mounted widget injects its stylesheet at RUNTIME, so it beats yours on source order whatever the specificity. TinyMCE puts its skin CSS into <head> when the editor mounts β€” always after mobile.css. Rules aimed at .tox-* need !important or they silently lose. The tell is a fix that looks like it half worked: hiding the upsell badge (a display rule that happened not to collide) succeeded while flex-wrap: nowrap on the menubar did nothing, which read as "mostly fixed" until it was measured. When a rule aimed at a mounted widget appears to partly work, measure which half.

  • Reclaim a third‑party widget's own chrome before adding your own. In TinyMCE at 360px the menubar wrapped to four rows and an upsell badge sat beside it β€” ~130px of a 720px screen gone before a word could be typed. Scrolling the menubar sideways (rather than hiding it, which would have taken Table and Format with it) plus dropping the badge returned 78px, measured as typing‑area height.

  • Build your own full screen, don't borrow the widget's. TinyMCE's fullscreen plugin was loaded and one line away, but this init's toolbar has no fullscreen button and its only other exit is the View menu β€” a phone user could get stuck in it. A position: fixed; inset: 0 overlay with your own always‑visible Close, plus a history.pushState so the device back button exits, is a dozen lines and cannot trap anyone.

  • Harvest a translated word, don't write one. common.back was needed for a shortened mobile button. Rather than an EN‑only key that falls back silently in 23 locales, each locale's common.php took its own existing translation of the same word from change-management.php. Zero invention, zero fallback, and the generic word ends up in common where the next module finds it. Worth checking for before assuming a new string is unavoidable.

  • ⚠️ Grep a module for localStorage before bringing it along. Two of the four modules so far have carried a saved DESKTOP mode that re‑applies itself on a phone β€” the tickets ticket‑popout (#762) and the knowledge editor pop‑out (#1000), which forces a fixed 340px property panel onto a 360px screen. Neither is visible in the markup, both look like the page is broken for one user and fine for everyone else, and the fix is the same: wrap the function that applies it, leave the stored preference alone, keep a CSS backstop.

  • CSS: assets/css/mobile.css β€” one @media (max-width: 768px) block, LAYERS 1–16.

  • JS: assets/js/mobile.js β€” one matchMedia‑gated IIFE.

  • Related: Mobile‑Friendly (strategy & iOS lessons), Mobile: Tickets (what was built), Mobile: Calendar, Theming & Dark Mode.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally