Mobile Friendly - edmozley/freeitsm GitHub Wiki

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. Service Status is the fifth, and the first that needed no JavaScript at all — three <link> tags and a CSS layer. Watchtower is the sixth: the attention dashboard was 864px wide on a 360px screen before opting in, and its card metrics became an aligned grid rather than a ragged wrapping row. Problem Management is the seventh, and had the worst starting point of the lot — a fixed sidebar left the list 110px wide on a phone, which every automated check passed. Change Management is the eighth, and half of it came free by inheriting layers built on shared class names. Software is the ninth, and went furthest on that: three of its six pages needed nothing at all, including its 1555-row inventory table, which picked up a finished card feed built for Asset Management because both modules had named the table the same thing. Tasks is the tenth: its Kanban board became one status per screen with a swipe between them, and its detail panel is the round that proves a headless harness can clear a screen that is broken on a real phone — the fault was a native date field, which iOS draws with the month spelled out and a desktop browser does not. Morning Checks is the twelfth (#1269), and turned up a general trap: the module had its own @media block predating the rollout, so a third file was styling the screen — before styling anything for a phone, find what is already styling it. Forms is the thirteenth, and the round where two of its four real faults were invisible to every containment check: a submissions page 1,400px wide inside a container that scrolled, so the document measured clean, and an approvals sidebar that squeezed the content to 100px with its headings at clientWidth: 0. It also ends a long‑standing compromise — a card feed can now keep its column headings, harvested from the table's own translated <thead> instead of hardcoded into the stylesheet. Contracts is the fourteenth and by some way the largest — 22 pages, including the thirteen‑page RFP Builder — and the round that found nine modal shells in one module where LAYER 3 knew about one, which matters because LAYER 3 carries the anti‑zoom rule and an unreached sheet does not degrade, it switches the whole mobile layer off. It also proved that a CSS‑only opt‑in hides a module's navigation: the views drawer is parked off‑screen until mobile.js injects the button that opens it. Honest state: good for the core "on‑the‑go analyst" flow, not yet mobile‑complete — see Where it stands at the bottom. Tickets settings followed in #1005 — 18 tabs, and the first page that had to opt out of a LAYER 2 rule.


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.

⚠️ This section used to say the Network Mapper canvas and the Process Mapper "stay desktop‑first", full stop, and that was too coarse. All three canvases have since been brought along, and they landed in three different places — which is the useful part:

the drag what shipped
Process Mapper hand‑rolled, and every per‑step action is behind contextmenu read‑only, said plainly
Network Mapper hand‑rolled, but placing and deleting were the missing half read‑only, then place + move + delete by finger when Ed asked
Workflow hand‑rolled, but the layout is auto‑generated fully usable, with no touch‑drag layer at all

🔑 So the question is not "does the drag work on touch?" but "what is the drag FOR?" — decoration on a generated layout needs nothing; the only way to place or remove a thing needs replacing. Say which of the three you are shipping, out loud, before writing anything. The Gantt timeline is still untouched.

⚠️ "Drag is out of scope" used to be written here as a blanket rule, and that was wrong. A drag built on HTML5 drag‑and‑drop works on a phone — iOS Safari has driven it from a long press since iOS 11 — which is why reordering checks in Morning Checks settings and fields in the Forms builder both work on a device today. Only a hand‑rolled mousedown/mousemove drag (a resize divider, a mapper canvas) has no touch events and genuinely cannot work. Check which one a module uses before ruling anything out: Techniques §22.

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=62">   <!-- opt in -->
...
<script src="../assets/js/inbox.js?v=53"></script>
<script src="../assets/js/mobile.js?v=26"></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–29). 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.

⭐ The one design decision that comes up every single time: a wide table becomes a card feed, or it keeps its columns and scrolls sideways. Both are defensible, so it gets re-argued on every module. The rule — and the tell that settles most cases, does a column hold prose? — is written up as Techniques §11: Card feed or horizontal scroller?. Read it before deciding, not after.

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)

📌 Audit tinymce.init on every module. A sweep in #1388 found eleven rich‑text editors in the product and six of them unguarded, three of those inside modules already shipped as mobile‑friendly. CSS cannot reach into a TinyMCE iframe, so the guard has to be in each editor's own content_style — and it is therefore missed every time an editor is added to a page after that page's mobile round. There is no desktop risk to weigh: pointer: coarse cannot match a mouse.

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 don't all map to touch — but they are not all the same thing. Split the case. HTML5 drag‑and‑drop (draggable="true" + dragstart) works on a phone: iOS Safari has driven it from a long press since iOS 11, and it only needs touch-action: none on the handle so the page does not scroll instead — which is how reordering works today in Morning Checks settings and the Forms builder. A hand‑rolled mousedown/mousemove drag (a resize divider, a mapper canvas) genuinely has no touch events and stays out of scope. See Mobile: Forms — this entry previously said all drag was out of scope, and that was wrong.
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 Done — the whole module. Inbox done deep and still the reference implementation; Settings added in #1005 (18 tabs, 17 tables, the largest settings screen in the product); and LAYERS 37-39 finished the remaining fourteen pages. ⭐ Every one of those fourteen measured 1404px wide on a 360px screen, to the pixel, because they share the desktop nav bar — opting them in fixed all fourteen at once and five then needed nothing further, the dashboards inheriting .dashboard-page and .widget-grid from Assets and Software for the fourth time. The rota keeps all three readings behind a sticky footer, because which one you want is a preference rather than a design decision (Ed's call), with zero new translation keys — and the first attempt built the chooser out of the cells it was filtering, so each view offered one choice and no way to reach another while the probe reported all three working (§31). 🔴 Users was the quietest fault in the rollout: tapping a user did nothing and had always worked, loading 1,748 characters into a pane 0px wide at x=360 (§32). Mailbox activity was §30 plainly — an 80px content pane beside a fixed 280px sidebar, reported as perfectly contained by a clipping body — and triage ran to 843px with four cells unreachable; both are card feeds now. CSAT gave the customer's comment 57 pixels and had measured clean twice, once for having no data inside its default 30-day window and once because the crushed-cell check wanted a taller cell (§33). The public CSAT survey works on a phone, which matters most of the lot. ⬜ Open: the day view is reported as using half the screen and does not reproduce (#1760-#1769). A phone polish round for 3.1.2 (#2165-#2173) followed: the attachments popup as cards, a user's actions in a sticky footer (LAYER 39a2), and long-press Link to task given its own list, because its picker had been opening inside the hidden Links sheet. 🔴 A ticket opened by id - a deep link or a linked-ticket pill - loaded behind the inbox list, because the pane stack wrapped selectEmail() and not loadTicketById() Mobile: Tickets
Domains Done — seven pages (LAYER 41). Built after the rollout with mobile.css linked correctly, so nothing spilled sideways — and still a screen and a half of search, views and filters above the register's list, a ten-column table cut off after Status, tabs wrapping into three rows and every field at 13px. The register's sidebar went into a §4 sheet behind a Filters bar that shows the current view; the table and the accounts list became card feeds with harvested headings; stats two-up; 16px module-wide. Two lessons: a delegated listener must use the capture phase when the page redraws the thing that was tapped, and a zero-height full-width flex item placed by order is a forced line break when a card's shape depends on the data. An inline 100vh shell became a class with identical values, proven by the desktop control (identical, 5,695 boxes) Mobile: Domains
People Done - five pages (LAYER 42). Built after the rollout with mobile.css linked and its own 640px card block, so nothing spilled - and four of five pages could not be scrolled by a finger: <body> clips, and with <html> visible that hidden reaches the viewport, while scrollTo() still moved it, so a programmatic scroll check would have passed. The page container takes the scroller role (41a's fix); the list's filters went from 320px to a 109px strip; every table became a card with a pill on line one and labels only where a value means nothing alone, never on an empty cell; the module's 640px block folded into the layer (LAYER 26's precedent). Desktop control identical, 4,723 boxes Mobile: People
Projects Done - three pages (LAYER 44). Built after the rollout with mobile.css linked after projects.css and markers on every page, so nothing spilled off the document - and two pages were unusable: a 240px sidebar left the portfolio's cards 120px wide, and a project's banner put icon, text and a 92px ring in one row, leaving the name 10px wide and the banner 755px tall. The sidebar went into a §4 sheet behind a Filter bar that shows the current view (Domains 41's shape); a project's Back / Edit / Delete into a sticky footer (41i's); tabs swipe; task rows wrap; 16px module-wide. ⭐ Moving a task between phases is a drag, so each row has a phase picker whose choice dispatches the dragstart and drop the page already listens for - the page's own moveTask() does the work and projects-view.js is unchanged (Network Mapper's move). 🔴 Connections' grid kept projects.css's bare 1fr, so a nowrap label held the track at 452px in a 328px pane while the clipping shell measured a contained 360 (§30). And a matchMedia change never fires in a headless iframe - proved on Domains as the control - so the leave-mobile path was driven through an injected stub. Desktop control identical, 4,427 boxes Mobile: Projects
Checklists & SOPs Done — four pages, and the first module contributed from outside the project, built with mobile.css already linked so three of the four measured clean before anything was done. What was missing was scoping rather than rules: no page carried a data-mobile-page marker, and on the list page the link sat above the page's own <style>, so every same-specificity rule in mobile.css was losing on document order (§9) — a page with no link announces itself, a page with the link in the wrong place looks done. ⭐ The list page carried three faults stacked, each hiding the next: a grid whose minmax(340px, 1fr) is a floor rather than a ceiling, inside a .chk-main left 100px wide by a fixed 260px sidebar, holding a card that would not shrink because a grid item has min-width: auto as well. Fixing the first changed nothing measurable, which is the lesson — when a fix that should have moved something moves nothing, look outward before looking again at the rule. A second round then took the editor's Back and Save into a sticky footer as icons, put every field to 16px (§3: under that iOS zooms, the page spills, Safari reflows to desktop width and the whole media query stops matching), moved the list's scope panel into a §4 sheet behind a Filters button, and stopped the step role badges pushing the page sideways. ⬜ The module calls t() in PHP but never loads i18n.js, so its client-side strings are English-only (#1770-#1772) Mobile: Checklists
Assets Done — list + detail (#936), then the wide pages: Devices/Software tabs, History/Custody trails, table view, dashboard, settings, servers (#937); then Users and Manager access (#2108, LAYER 43), which had never been opted in at all - a 1,379px header and a detail nobody could reach. ⬜ The guide, the custom-fields guide, Labels and the widget library are still not opted in 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
Watchtower Done — top bar stacked, card grid single column, card metrics on aligned columns rather than a ragged wrapping row, settings + help; no mobile.js branch (#1179) Mobile: Watchtower
Problem Management Done — sidebar → header strip, status filters as a chip row, the sidebar steps aside while reading a problem, and the module's own modal class promoted to full-screen sheets (#1181) Mobile: Problem Management
Change Management Done — sidebar → header strip, approvals filters as a scrolling row, list cards stacked (their badges carried flex-shrink: 0), and table/calendar/settings/help inherited outright (#1184). Five later rounds took the detail and editor views: a sideways scrollbar that looked vertical (#1185), a sticky header taller than its scrollport (#1186), the rich-text editor full screen and then as cards with one editor built on demand (#1187, #1190), and the padding, risk row and date fields (#1188-#1193) Mobile: Change Management
Software Done — inventory and licence toolbars stacked, licences and the widget library as card feeds, settings padding trimmed; the inventory table inherited its layout outright from the Assets round (#1194) Mobile: Software
Tasks Done — Kanban board as one status per screen with snap‑swipe and a dots indicator, list view as a card feed, table/calendar/timeline/settings/help by opting in (#1205); then the detail panel: the paired rows stacked and a native date field that ignores width: 100% (#1206) Mobile: Tasks
Morning Checks Done — the seven‑column checklist as a card feed with the Green/Amber/Red buttons full width, settings and help; the module had its own pre‑rollout @media block, which was removed and folded in (#1269, then #1271‑#1278 from a device pass) ⬜ dedicated page still owed
Forms Done — all seven pages. The list's eight‑column table and the submissions table as card feeds, the approvals sidebar (which left the content 100px wide) as a chip row, the builder's rows and fields, and the first card feed to keep its column headings — harvested from the table's own translated <thead> rather than hardcoded (#1289) Mobile: Forms
Contracts Done — all 22 pages, the largest round of the rollout, plus two polish passes. Suppliers and Contacts each carry an Overview / directory switch with a search pinned to the bottom, a record fills the screen, and the shared search box became a full-screen sheet for all four modules that use it. The 260px sidebar becomes a chip strip (and its per-analyst hover mode, which is unopenable without a mouse, is neutralised), six list tables become card feeds with harvested headings while four comparison tables keep their columns and scroll, and nine module-specific modal shells are promoted to full-screen sheets by keying on the three overlays rather than the nine cards. Includes the thirteen-page RFP Builder, which inherited the whole shell because it is the same layout under a different prefix (#1362). The list page later got the Suppliers / Contacts treatment too (#2174): Overview / Contracts views and a sticky footer with Search, Add and a Filter sheet that drives the page's own party drop-down Mobile: Contracts
LMS Done — all six pages, and the most polished module in the rollout: ten rounds of Ed's feedback after it shipped (#1396-#1410). The build brought back the two panels a pre-existing 900px media query simply deleted — the player's course contents and the editor's lesson list, both primary navigation — as a slide-in sheet, and closed the tablet band that old breakpoint left behind (#1392-#1395). The polish rounds split a lesson into two pages, put the Progress filters behind one icon, reversed the one §11 scroller to a card feed once it was clear a phone shows three of its eight columns, and found that the console could not be scrolled at all — a page that fits the width but cannot be reached, which no check in this rollout had ever asked about (§28). The competency Tests pages (3.0.0) followed later as LAYER 29t (#2175): two card feeds, the builder restacked, and a bare count in a row of fields labelled through the row's own ::before Mobile: LMS
CMDB Done — all five pages, and the first module that mostly arrived ready: the object page already had its touch guards and its own stacking breakpoint, and data quality, settings and the guide needed nothing past the shell. The work was one page and one sentence. Browse's 260px class sidebar left the object list 100px wide, and the containment check could not have caught it — the page sets body { overflow: hidden }, so a clipping document can never report an overflow (§30). The sidebar became a chip strip. Then a screenshot found the empty state still saying “pick a class on the left” when the classes had moved to the top — a sentence that points the wrong way measures exactly like one that does not (#1448-#1449) Mobile: CMDB
Network Mapper Done — all three pages, and the second canvas after Process Mapper where a phone can read the map but not build it. The 240px class palette left the diagram 119px wide and became a sheet; the fifteen-button toolbar had crushed the diagram's own title to 4px, where neither flex-grow nor min-width: 0 could help it because there was no free space to grow into (§23); node details became a bottom sheet, where 58dvh copied from Process Mapper left 71px of map because this module has 240px of chrome above it rather than 48. Opting its guide in found two faults outside the module: that guide could not be scrolled at all, and the numbered contents strip has been inert on a phone in all seventeen guides since the first one shipped. Then Ed asked for the drag, and #1468 added placing, moving and deleting a node with a finger — all three together, because deleteSelectedNode() is bound to the Delete key alone, so placing without the other two would have been a one-way door. Not one line of network-mapper.js changed: each gesture ends by dispatching the event the module already listens for (#1464-#1468) Mobile: Network Mapper
Process Mapper Done — three pages, the first round scoped as read-only on purpose. The process list as a sheet (and its hover left-panel preference neutralised, which was unopenable on touch), the 749px toolbar contained, .pm-modal promoted, step types as a card feed, and the selected item as a bottom sheet at Ed's request (#1415-#1418) Mobile: Process Mapper
Workflow Done — all seven pages, and the third canvas but the first fully usable on a phone, which is a fact about the module: nodes are added by toolbar buttons, auto-stacked in one column, configured in the panel and deleted with a real button, so dragging one is decoration. The editor's 340px inspector is never closed, so it left the diagram 20px — the narrowest pane in the rollout; it now stacks, map over inspector, split as a percentage of the pane that holds them both rather than in dvh (LAYER 31f's lesson used rather than repeated). The rules list and the execution log become card feeds, and a 538px-tall filter stack halves. Opting the list pages in made them worse first — .container grew from 360px to 705px, because LAYER 2's flex body turned it into a flex item with a 637px table for a floor, and the pin that fixes it existed only for settings pages. Every red the probes raised this round was the probe. Ed then found the page the round had missed: system/webhooks/, linked from Workflow's own nav but living in another module, whose nine-column delivery log ran to 556px and had been hidden by a container that scrolled sideways by accident (#1469-#1471) Mobile: Workflow
System Done — 53 pages, the largest round of the rollout: the landing, twenty-odd admin screens, the 14 debug tools, the guide and its 19 topics. The file count is not the page count — three shared shells carry 43 of them, so the opt-in was 34 file edits. 🔴 The landing page could not be scrolled at all (5008px of cards), because LAYER 2 gives .main-container overflow: hidden for the ticket pane stack and mobile.css loads last by design — §9's rule cuts both ways, and a page that solved something for itself is not protected by having solved it first. Config tables scroll sideways; the 20 guides' tables become stacked blocks instead, because their last column is a sentence. Branding had a 1fr track laid out at 1282px inside 264 (the page's own breakpoint dropped the minmax(0, …) its desktop rule had) and six header/footer inputs at 55px — contained but crushed. Desktop proven by toggling the stylesheet off in place and diffing every element's box (#1472) Mobile: System
Reporting Done — five pages, the smallest round, and the strongest case yet for shared layers: the drawer, the JSON sheet, the whole Intune dashboard shell and the guide's scroller all arrived free, because the module is built from class names three earlier rounds had already answered. 🔴🔴 The landing page was showing one and a half cards out of three — a 700px row centred in 360 hangs 170px off both sides, so the first card sat at x = −170, off-screen and unreachable, while docScrollW read a perfectly contained 360. §30 in its cleanest form: a clipping body cannot report an overflow, so a card at a negative coordinate is invisible to every check that asks about the right-hand edge. The guide could not be scrolled at all (7091px, five of six sections unreachable). Both log tables become card feeds keyed on a data-log-type the renderers now stamp — two tables, one class, one container, and different columns wanting a heading, so the markup had to be taught to say which it was. And these are the first feeds whose table does not exist at load, which made mobile.js return silently before doing anything: FEEDS now takes a watch selector for the box that is there. The Intune drill-down, a six-column device list at 578px in a 320px floating dialogue, is a full-screen sheet of cards with Encrypted Yes labelled — the clearest case in the rollout of a cell that means nothing without its heading (#1482–#1486) Mobile: Reporting
System Wiki Done — seven pages, and the module whose every table was empty: the codebase has never been scanned on the development install, so the first probe run came back clean on all seven pages and was worthless. §28's an assertion that cannot fail is not passing in a third form, and the easiest of the three to walk past because the page looks finished — fixed by stubbing fetch and re-running each page's own loaders against realistic fixtures, with nothing written to the database. 🔴 A 280px folder tree beside the file list left the list 80px with a 1110px table inside it, clipped by two ancestors that both say overflow: hidden. The tree moves into a sheet — and 🔴🔴 closing it on every folder tap would have made api/tickets unreachable, because selectFolder() expands the tapped folder as well as selecting it, so the sheet would shut at the exact moment it revealed the next level down: a parent is a step, a leaf is an arrival, and the chevron in .tree-toggle already says which is which. Six of the eight could not be scrolled at all. The hover left panel is the third module to ship that dead end, and the first needing !important, because header.php emits its <style> inside <body> (§24) so the specificity route is closed. 🔴 The scan history had already inherited another module's card feed — LAYER 15b's .history-table is unscoped (§15) — leaving seven values one per line with no headings; the tell was a <th> measuring 0. A file's page has six tables and takes three answers, keyed on column count rather than position, because the Classes section only exists when the file has classes. ⭐ Search's results were the only list in twenty-one modules that arrived already in the right shape (#1487–#1490) Mobile: System Wiki
Self-Service Portal Done — the public-facing half, and the one module that keeps its OWN mobile layer rather than opting into mobile.css (Ed's call: it shares none of the analyst shell, so pulling 12,500 lines of rules written against analyst markup onto a public surface would drag §15's hazard with them). Part one (#1207) put the destination nav into a ☰ drawer — Ed's correction, and the codebase proved him right, because the chip rows in this rollout are for FILTERS, not destinations — and found the drawer on screen, 44px per link, and completely untappable, because a position: sticky; z-index: 100 header creates a stacking context its z-index: 3000 child resolves inside. Part two (#1491–#1494): 🔴🔴 the guide could not be scrolled at all (7118px, ten of eleven sections unreachable) from three files that were each individually right — and the fix needed the whole ancestor chain, because .help-container is not a child of <body> and a flex: 1 is measured against its own parent. Its numbered contents strip was tappable and inert for the same reason all seventeen analyst guides were. 🔴 Opening a ticket from the dashboard meant hitting a 17px line of text, because min-height is inert on an inline box — the third rule in this module found never to have applied to anything — and the <tr> has no handler, so that link was the only target. ⚠️ Fixing it at 44px on both anchors per card added 50px of empty space to each: every measurement was happier and the card looked broken, which only a screenshot showed. And §15 again: Your requests and Recent tickets are both .ticket-table with three columns and five, so index-keyed rules were styling a request's status as a ticket's subject Mobile: Self-Service
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:

  • Nineteen modules deep — Tickets, Assets, Calendar, Knowledge, Service Status, Watchtower, Problem Management, Change Management, Software, Tasks, Morning Checks, Forms, Contracts, the LMS, CMDB, Process Mapper, Network Mapper, Workflow and System are reflowed; the remaining modules have the shell (waffle/landing) but not their bodies. Process Mapper is readable, not authorable — a deliberate limit, stated on its page. Network Mapper started there and then gained placing, moving and deleting a node by finger (#1468); only its connectors are still desktop-only. Workflow is fully usable, because its layout is generated rather than dragged.
  • 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–36 (148 pages opted in). ⚠️ Two layers are numbered 30 (Process Mapper and CMDB, shipped in the same week); Network Mapper is 31 and Workflow is 32.
  • 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.
⚠️ **GitHub.com Fallback** ⚠️