Skip to content

Mobile Friendly

Ed Mozley edited this page Aug 9, 2026 · 8 revisions

Mobile‑Friendly FreeITSM

How FreeITSM is being made usable on a phone β€” the strategy, the one hard rule that keeps it safe, the reusable patterns, the hard‑won device lessons, and how to bring a new page along. Like Theming & Dark Mode, this is a gradual, non‑breaking rollout layered on top of the existing desktop app rather than a rewrite or a separate mobile site.

Status: Tickets (the inbox) is done deep and is the reference implementation β€” the master‑detail stack, the drawers, and every reading‑pane action panel (note, reply, forward, schedule, Ask‑AI) work on a phone. The landing page (module launcher) and the waffle menu are mobile‑friendly on every page. Assets is the second module brought along β€” its list, its detail pane, and its table view, dashboard, settings and servers pages. Calendar is the third: the month grid becomes coloured dots with a tapped‑day agenda, and its table and settings pages inherited their treatment from the Assets round at no CSS cost. Knowledge is the fourth, and the first where search is the primary action β€” so it goes into the sub‑bar rather than behind a button, and an article's author‑written HTML (wide images, tables, code) is contained so it can't take the layout down with it. Honest state: good for the core "on‑the‑go analyst" flow, not yet mobile‑complete β€” see Where it stands at the bottom.


Who is this for?

The target is the on‑the‑go analyst β€” someone away from their desk who needs to triage the inbox, read a ticket, reply, change its properties, and link/record things. It is deliberately not an attempt to make every one of the ~20 modules fully usable on a phone. Some surfaces (the Network Mapper canvas, the Process Mapper, the Gantt timeline, drag‑between‑columns Kanban) are inherently desktop interactions and are out of scope for mobile β€” they stay desktop‑first.

So the scope is a focused mobile experience for a handful of high‑value journeys, done well β€” Tickets first, then Assets.


The one hard rule: never break desktop

This is the north star, and it's what makes the rollout safe to do incrementally:

Every mobile style lives inside a @media (max-width: 768px) block, and every mobile behaviour is gated on matchMedia('(max-width: 768px)').

The core is two shared files:

File What it is Safety
assets/css/mobile.css Only @media (max-width: 768px) rules β€” nothing at the top level. Above 768px the rules don't exist, so the desktop render is byte‑identical β€” provable, not by inspection.
assets/js/mobile.js A single IIFE whose every branch checks mq.matches first. On a wide screen every handler no‑ops; injected elements are hidden.

This mirrors the dark‑mode safety net: just as var(--token, #fallback) made tokenising a stylesheet non‑breaking, the @media/matchMedia gate makes adding mobile behaviour non‑breaking. You can convert a page and know with certainty you changed nothing for desktop users.

Verify the CSS gate holds β€” this should print nothing:

grep -nE "^[^[:space:]/}].*\{" assets/css/mobile.css | grep -v "@media"

Corollary β€” injected chrome must be hidden off‑mobile. mobile.js injects DOM (a back/folders sub‑bar, a views hamburger, section sheets). Because mobile.css is @media‑only it cannot set a desktop default of display:none. So injected elements are either created with an inline style="display:none" (sheets) or toggled by a syncBar() function that runs on load and on every matchMedia change.

Where mobile CSS actually lives β€” two placements

mobile.css is the home for page‑specific mobile rules (the tickets inbox links it). But there are two important exceptions, both learned in practice:

  • Shared components put their mobile @media in the component's own file. The waffle menu is on every page; its mobile drawer rules live in includes/waffle-menu.php's own <style> (behind an @media block), not in mobile.css β€” otherwise the drawer only works on the one page that links mobile.css. Rule of thumb: if a shared component needs mobile treatment app‑wide, gate it in the component's file.
  • A standalone page can carry its own @media block rather than link mobile.css. The landing page (module launcher) does this β€” because mobile.css carries tickets‑inbox body/pane rules (body{height:100dvh}, .main-container{overflow:hidden}) that would clip a scrolling launcher page. When mobile.css's baggage would fight a page, a self‑contained block is cleaner.

Rollout model

Per‑page opt‑in. A page joins the mobile experience by linking mobile.css (and, where it needs behaviour, mobile.js) in its <head>, after its own stylesheet so the @media rules win on ties:

<link rel="stylesheet" href="../assets/css/inbox.css?v=40">
<link rel="stylesheet" href="../assets/css/mobile.css?v=31">   <!-- opt in -->
...
<script src="../assets/js/inbox.js?v=53"></script>
<script src="../assets/js/mobile.js?v=14"></script>           <!-- after the page's JS -->

mobile.js loads after the page's own script so it can wrap the globals that page already exposes (see below) rather than editing them. Bump the ?v= query whenever you change a file.

⚠️ Safari caches the HTML page itself. Bumping ?v= busts the CSS/JS, but Mobile Safari also caches index.php, so it may keep requesting the old version numbers until you hard‑clear. A stale mix (new JS + old CSS) produces baffling "it got worse" symptoms. Always full‑clear Safari before judging a fix on‑device.


The three navigation layers

The hardest conceptual problem on mobile is navigation: how do you move between modules, between views inside a module, and between panes that normally sit side‑by‑side? FreeITSM answers each with a distinct affordance.

Layer Desktop Mobile
Between modules the waffle app‑launcher (top‑left) the same waffle panel, restyled into a full‑height left slide‑in drawer (with a βœ• close button) β€” on every page
Between views in a module (e.g. Inbox / Dashboard / Calendar) the .header-nav button row a ☰ hamburger at the top‑right opening a right slide‑in drawer
Between panes in a view (e.g. folders / list / conversation) side‑by‑side split panes an Outlook‑style master‑detail stack β€” one pane on screen, slide between them

The two drawers are symmetric β€” modules left, views right β€” which keeps them easy to tell apart.


Reusable patterns

These are the building blocks. Tickets uses all of them; future modules should reach for the same vocabulary. In mobile.css they're organised into numbered LAYERS (1–17). For the full code‑level catalogue β€” including the Shadow‑DOM per‑message horizontal scroll, the iOS "reflow to desktop" trap, and how it's all verified headlessly β€” see Techniques & Tricks.

1. Master‑detail pane stack

A multi‑pane split becomes a one‑pane‑at‑a‑time slide stack. State lives in a single attribute on <body> (data-mobile-pane), and the panes are absolutely positioned and slid with transform: translateX(...). Wiring it to history.pushState makes the device back button pop the stack, which is what makes it feel native rather than like a resized website.

2. Modal β†’ full‑screen sheet

The canonical .modal-content (used app‑wide) fills the screen on mobile β€” so the reply composer, pickers and forms get room instead of a cramped centred box. One rule, every modal benefits.

3. Full‑panel fill for a compose/edit modal

The sheet from #2 is full‑screen, but the modals were built as centred desktop boxes, so their single field sits at a fixed height with dead space below. Make the modal a flex column and let its main field grow to fill (scoped by #id so multi‑field forms keep their sizing). Done for the note box, the reply/forward editor (TinyMCE, found via :has(#emailBody)), etc.

4. Section β†’ its own sheet

When a section crowds a small screen, relocate its DOM node into a full‑screen sheet opened by a button. Config‑driven, so adding another panel is one line.

5. Bottom action bar + overflow

Action buttons drop their text labels to icons only and move to a bottom bar (via flex order) below the scrolling content. When there are too many, keep the first few and push the rest into a "β‹―" overflow popover so it stays a single row.

6. Collapsible sections

Verbose blocks (e.g. a ticket's From/To/Date/Cc) start collapsed behind a tappable summary + chevron, Gmail‑app style.

7. Side‑panel β†’ full‑width

A fixed‑width desktop side panel (e.g. the 420px Ask‑AI chat) becomes full‑width 100dvh on a phone. If it's already a flex column, the body scrolls and the input bar stays pinned for free.


Device reality: the iOS lessons

These are the bugs that separate "works in DevTools device mode" from "works on an actual iPhone." Every one of them cost a test‑loop round; bank them.

1. The <16px focus‑zoom cascade (the big one)

iOS Safari auto‑zooms when you focus any form field whose font is smaller than 16px. In a full‑screen modal sheet this cascades disastrously: the zoom makes the sheet spill wide β†’ Safari reflows the page to a desktop‑width layout β†’ the max-width: 768px rules stop matching β†’ the phone modal reverts to the big centred desktop box. It looks like everything "went to desktop mode."

Fix: force 16px on every focusable field on mobile.

.modal-content .form-input,
.modal-content .form-textarea,
.modal-content .form-select { font-size: 16px; }

For rich‑text editors the content lives in an iframe CSS can't reach, so fix it in the editor's config β€” and key it off the device, not width, so desktop is untouched:

// TinyMCE content_style β€” 14px desktop, 16px on touch so iOS doesn't zoom.
content_style: 'body { font-size: 14px; } @media (pointer: coarse) { body { font-size: 16px; } }'

@media (pointer: coarse) reflects a touch device (phones) vs pointer: fine (mouse), so desktop stays byte‑identical β€” cleaner than a width query, which is unreliable inside an iframe.

2. Native date/time inputs spill off the right

iOS gives native date/time inputs an intrinsic width that ignores width: 100%, so the grey field pushes past the right edge of a narrow sheet. A max-width cap alone doesn't tame it. Fix: -webkit-appearance: none (makes iOS honour the box model) + max-width: 100% + reset the internal value margin. The native picker still opens on tap.

.modal-content input[type="date"],
.modal-content input[type="time"] {
    -webkit-appearance: none; appearance: none;
    width: 100%; min-width: 0; max-width: 100%; box-sizing: border-box;
}
.modal-content input[type="date"]::-webkit-date-and-time-value { margin: 0; text-align: left; }

3. The keyboard doesn't shrink a position: fixed sheet

A bottom‑anchored input (note box, chat input) can end up behind the on‑screen keyboard, because the keyboard overlays a fixed full‑height sheet rather than resizing it. A visualViewport JS handler to fit the sheet above the keyboard was tried and made things worse (unpredictable on iOS) and was removed. This is the top remaining rough edge β€” it needs a safer approach, not the naive one.

4. Desktop localStorage modes leak onto mobile

A desktop‑only body class driven by a saved preference can be re‑applied on the phone and break the mobile layout. The real example: a saved tickets_popout (full‑screen reading‑pane mode) made inbox.js add body.ticket-popout on every ticket open β€” including on a phone β€” and its .email-list-container { display:none } hid the list, so the mobile Back button had nothing to return to. Fix: neutralise the mode on mobile at the source β€” wrap the function that manages the class and strip it when mq.matches, with a CSS backstop. Watch for other desktop‑only body classes doing the same.


Reaching a page's JS without editing it

mobile.js never edits the big inbox.js. Top‑level let/function declarations in a classic script share the global lexical environment, so mobile.js can:

  • Read the page's state directly β€” currentEmail, ticketAttachments (bare identifiers, typeof‑guarded).
  • Wrap the page's global functions to add mobile behaviour after they run β€” selectEmail, selectFolder, renderAttachmentInfoBar, syncPopoutToTicketState.

This keeps the renderer untouched (desktop safe) while layering mobile behaviour on top. The one justified inbox.js edit so far was a single content_style string (see iOS lesson #1) β€” because CSS genuinely can't reach inside a TinyMCE iframe β€” and even that is gated on pointer: coarse.


How to make a page mobile‑friendly

  1. Link mobile.css (and mobile.js if it needs behaviour) in the page <head>, after the page's own CSS/JS. Bump the version query. (For a shared component, put the @media in the component's own file instead β€” see placement.)
  2. Fix the height model. Desktop shells often use height: calc(100vh - 48px), which assumes a fixed header β€” wrong once the header wraps. Switch the body to a flex column so the header takes its natural height and the content flexes (flex: 1; min-height: 0). Use 100dvh, not 100vh.
  3. Collapse side‑by‑side layouts into the master‑detail stack (or stack vertically if there are only two).
  4. Relocate crowded sections into sheets; tighten padding; icon‑only dense button rows (+ overflow); collapse verbose blocks; fill compose modals.
  5. 16px every focusable field (iOS lesson #1). Check native date/time inputs (lesson #2).
  6. Wrap, don't edit. Wrap the page's existing global handlers from mobile.js.
  7. Keep every rule inside the @media block and every behaviour behind mq.matches.
  8. Test on a real device β€” full‑clear Safari first β€” there is no substitute.

Challenges & solutions (general)

Challenge Solution
The whole app is a fixed‑height desktop shell (100vh, overflow:hidden, split panes). Per‑pattern collapse to single‑column / master‑detail; replace fragile calc() heights with flexbox + 100dvh.
Drag‑based surfaces (canvas editors, Gantt, Kanban) don't map to touch. Out of scope; stay desktop‑first.
iOS focus‑zoom wrecks full‑screen sheets. 16px on every focusable field (+ pointer: coarse for iframe editors). See iOS lessons.
Native date/time inputs spill off the right. -webkit-appearance: none + max-width: 100%.
The on‑screen keyboard hides a bottom‑anchored input. Open rough edge β€” naive visualViewport fix made it worse and was reverted.
Desktop localStorage modes leak onto mobile and break layout. Neutralise at the source (wrap the state function, strip the class when mq.matches) + CSS backstop.
Injected mobile chrome leaking onto desktop. syncBar() mq‑toggle + inline display:none defaults on injected nodes.
The live pages need a login, so you can't just point a headless browser at them. Render a standalone harness that links the page's real CSS (extract its inline <style> to a file) plus hand‑written markup, inside an iframe pinned to 360px β€” a true phone viewport, unlike --window-size=360, which Windows clamps to ~500px and then crops. Measure documentElement.scrollWidth against innerWidth rather than eyeballing the screenshot, and run the same harness at 1100px as a desktop positive control. A real device pass is still the final word.
Safari caches the HTML page, so ?v= bumps don't fully bust it. Hard‑clear Safari before judging any on‑device fix.

Module status

Area State Page
Tickets (inbox) Done deep β€” the reference implementation Mobile: Tickets
Assets Done β€” list + detail (#936), then the wide pages: Devices/Software tabs, History/Custody trails, table view, dashboard, settings, servers (#937) Mobile: Assets
Calendar Done β€” month grid becomes dots + a tapped‑day agenda, week scrolls in its own box, sidebar β†’ sheet; all four of its pages, including the settings categories card feed and the help guide (#998) Mobile: Calendar
Knowledge Done β€” search moved into the sub‑bar (the primary action), tags into a sheet, author‑HTML containment for the article body, the editor's localStorage pop‑out neutralised, plus the review card feed, assistant, settings and help (#1000) Mobile: Knowledge
Landing (module launcher) Done β€” adaptive icon grid β€”
Waffle menu (module nav) Done β€” drawer on every page β€”
Other tickets pages (dashboard/calendar/users/settings/help) Shell only β€” bodies not reflowed β€”
Other assets pages (library/labels/assign‑tags/help) Shell only β€” bodies not reflowed β€”
Everything else Not started β€”

When another module is brought along, add a Mobile-Friendly-<Module>.md page here and a row above.


Where it stands (honest)

For a free, solo‑built product this is a genuinely strong mobile experience on the core journey β€” better than most free ITSM tools, which have no mobile or a broken responsive layout. But it's good for the core flow, not mobile‑complete:

  • Four modules deep, not broad β€” Tickets, Assets, Calendar and Knowledge are reflowed; the other ~16 modules have the shell (waffle/landing) but not their bodies.
  • Bottom inputs vs the keyboard β€” the top remaining rough edge (see iOS lesson #3).
  • Accessibility β€” icon‑only buttons and injected chrome need an aria-label sweep.
  • No PWA / offline / push β€” "on the go" would eventually want installable + push.
  • All hand‑crafted per page β€” no systematic responsive framework, so breadth keeps costing per‑page effort.
  • Self‑verification works but is hand‑built β€” a headless 360px iframe measuring scrollWidth, plus a desktop positive control, catches real overflow bugs (it found a 776px settings overflow no screenshot made obvious). It is assembled per page, not a test suite, and a real device pass is still the final word.

Recommended next move: finish Tickets to a defensible "done" (kill the keyboard‑input issue + an aria sweep), then make a deliberate breadth‑vs‑depth call β€” either bring 2–3 more high‑mobile‑value journeys (approvals / morning checks / service status) or invest in a PWA shell. The pitch decides: "tickets from your pocket" (depth) vs "your whole ITSM in your pocket" (breadth/PWA).


Reference

  • CSS: assets/css/mobile.css β€” one @media (max-width: 768px) block, LAYERS 1–17.
  • JS: assets/js/mobile.js β€” one matchMedia‑gated IIFE.
  • Shared‑component mobile CSS: includes/waffle-menu.php (@media block in its own <style>).
  • Code‑level trick catalogue: Mobile: Techniques & Tricks.
  • Related: Theming & Dark Mode (the same gradual, non‑breaking philosophy), Architecture.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally