-
Notifications
You must be signed in to change notification settings - Fork 15
Mobile Friendly
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. Service Status is the fifth, and the first that needed no JavaScript at all β three
<link>tags and a CSS layer. Honest state: good for the core "onβtheβgo analyst" flow, not yet mobileβcomplete β see Where it stands at the bottom.
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.
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 onmatchMedia('(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.
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
@mediain the component's own file. The waffle menu is on every page; its mobile drawer rules live inincludes/waffle-menu.php's own<style>(behind an@mediablock), not inmobile.cssβ otherwise the drawer only works on the one page that linksmobile.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
@mediablock rather than linkmobile.css. The landing page (module launcher) does this β becausemobile.csscarries ticketsβinboxbody/pane rules (body{height:100dvh},.main-container{overflow:hidden}) that would clip a scrolling launcher page. Whenmobile.css's baggage would fight a page, a selfβcontained block is cleaner.
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 cachesindex.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 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.
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β18). 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.
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.
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.
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.
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.
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.
Verbose blocks (e.g. a ticket's From/To/Date/Cc) start collapsed behind a tappable summary + chevron, Gmailβapp style.
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.
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.
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.
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; }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.
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.
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.
-
Link
mobile.css(andmobile.jsif it needs behaviour) in the page<head>, after the page's own CSS/JS. Bump the version query. (For a shared component, put the@mediain the component's own file instead β see placement.) -
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). Use100dvh, not100vh. - Collapse sideβbyβside layouts into the masterβdetail stack (or stack vertically if there are only two).
- Relocate crowded sections into sheets; tighten padding; iconβonly dense button rows (+ overflow); collapse verbose blocks; fill compose modals.
-
16px every focusable field (iOS lesson #1). Check native
date/timeinputs (lesson #2). -
Wrap, don't edit. Wrap the page's existing global handlers from
mobile.js. - Keep every rule inside the
@mediablock and every behaviour behindmq.matches. - Test on a real device β fullβclear Safari first β there is no substitute.
| 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. |
| 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 |
| Service Status | Done β service board twoβup, incidents as a card feed, settings + help; no mobile.js branch at all (#1003) |
Mobile: Service Status |
| 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.
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:
- Five modules deep, not broad β Tickets, Assets, Calendar, Knowledge and Service Status are reflowed; the other ~15 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-labelsweep. - 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).
- CSS:
assets/css/mobile.cssβ one@media (max-width: 768px)block, LAYERS 1β18. - JS:
assets/js/mobile.jsβ onematchMediaβgated IIFE. - Sharedβcomponent mobile CSS:
includes/waffle-menu.php(@mediablock in its own<style>). - Codeβlevel trick catalogue: Mobile: Techniques & Tricks.
- Related: Theming & Dark Mode (the same gradual, nonβbreaking philosophy), Architecture.
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)