-
Notifications
You must be signed in to change notification settings - Fork 16
Mobile Friendly Change Management
Change Management is the eighth module made mobileβfriendly. Same hard rule: one @media (max-width: 768px) block, desktop byteβidentical.
π§° Codeβlevel catalogue of the techniques: Mobile: Techniques & Tricks.
Shipped as #1184, commit 9d8a16d6. LAYER 23.
| Page | State |
|---|---|
change-management/ β list, detail, editor |
Done β sidebar β header strip, cards stacked, pane hiding (LAYER 23) |
change-management/approvals.php |
Done β sidebar becomes a single scrolling filter row |
change-management/table.php |
Done by opting in β inherits .dt-wrap from the Assets round |
change-management/calendar.php |
Done by opting in β inherits the Calendar layer |
change-management/settings/ |
Done by opting in β the shared settings rules, plus the body marker |
change-management/help.php |
Done by opting in β inherits 16h |
π Half the module cost nothing. That is the return on building earlier layers around shared class names (
.dt-page,.calendar-grid,.container) rather than page ids β a new module that reuses a component inherits its mobile treatment for free.
viewport = 360 docScrollW = 1286 β on ALL SIX pages
index.php sidebar 280 β list pane 80px
approvals.php sidebar 260 β list pane 100px
1286px on a 360px screen, and an 80px column of content β the narrowest yet recorded here. The 1286 was the shared header, so opting in fixed it outright; the 80px was not touched by that at all.
It mirrors LAYER 22 closely, because the module is built the same way: a fixed sidebar beside a flexible pane, plus a list/detail swap the module already does in its own JS.
The sidebar holds three sections: Search, Status filters, New change β in that order. Making the filters span both columns should have left the two buttons sharing the top line. It didn't, because the filters sit between them in the DOM, so the grid placed Search on row 1, filters on row 2, and New change on row 3.
.changes-sidebar .sidebar-section:has(.status-filter-list) {
grid-column: 1 / -1;
order: 1; /* β move the filters LAST */
}order moves the filters after both buttons, which puts the two actions side by side and reads better anyway: do the thing, then narrow what you are looking at.
Its filters are direct children of the sidebar, with no list element around them, so there is nothing to turn into a strip. The sidebar itself becomes the scrolling row, with the "Filter" heading riding along at the start rather than being hidden β it costs one word of scroll and it is the only thing naming what the chips are.
var showView = window.showView;
window.showView = function (view) {
var out = showView.apply(this, arguments);
setPane(view === 'detail' || view === 'editor' ? view : 'list');
return out;
};A cleaner hook than Problem Management's two wrappers: showView('list' | 'detail' | 'editor') is a single synchronous function, so there is no promise to wait on and no failure path to guard.
Every containment check passed β docScrollW === innerWidth, zero uncontained elements β while the change list looked like this: priority and status pills marching off the rightβhand edge, and the assignee colliding with the date.
.change-card { display: flex; align-items: center; gap: 16px; } /* ref | title+meta | badges */
.change-card-badges { flex-shrink: 0; } /* β four badges that will not give way */Four badges that refuse to shrink make the card wider than the screen whatever the panes do, and because an ancestor scrolls, the document never widens. The page was contained; the content was off the edge of the box it sat in.
.change-card { flex-direction: column; align-items: stretch; gap: 6px; }
.change-card-title { white-space: normal; overflow: visible; } /* two lines beat an ellipsis here */
.change-card-meta { flex-wrap: wrap; gap: 2px 12px; }
.change-card-badges { flex-wrap: wrap; flex-shrink: 1; }π
flex-shrink: 0is the single most common cause of a row that will not fit a phone. It is correct on desktop β it stops badges being squashed into illegibility β and it is exactly wrong at 360px, where something has to give. Grep a module's CSS for it before wondering why a row overflows.
The first sweep reported table.php as having a table overflowing its parent: 938 > 326. It wasn't a bug. The parent is the scroller β .dt-wrap with overflow-x: auto β so scrollWidth > clientWidth is precisely what a working scroller looks like.
// A table is only WRONG if it is wider than its box AND no ancestor scrolls.
let scrolled = false;
for (let p = t; p; p = p.parentElement) {
const ox = getComputedStyle(p).overflowX;
if (ox === 'auto' || ox === 'scroll') { scrolled = true; break; }
}Reβchecked against asset-management/table.php as a knownβgood control: identical readings. When a probe reports a fault on shipped code that has been through a device pass, suspect the probe first.
| Check | Result |
|---|---|
| All six pages at 360px | β contained, zero uncontained, no unscrolled table spills |
| Pane cycle: list β detail β editor β back | β
sidebar grid β none β none β grid
|
| Desktop @ 1200px and 1400px |
body block/row, sidebar 280px, cards still flex-direction: row
|
data-cm-pane on desktop |
β never appears, at any point in the cycle |
mobile.js parse check |
β with a negative control |
| Looked at, not only measured | β β and it is the only thing that caught the card bug |
- Preβexisting, not introduced here: at a 1200px desktop window this module's shared header needs 1282px, so the page scrolls sideways until roughly 1290. It is fine at 1400. That is a desktop header issue β this module carries more nav items than the others β and is deliberately left alone.
Ed, viewing a change on a phone. The vertical scroll had been working all along; the bar he could see was a horizontal one belonging to .changes-main, and dragging it moved almost nothing.
Written up in full as Techniques Β§12 β the short version is that .changes-main sets only overflow-y: auto, and per spec that makes overflow-x compute to auto too. It was silently a horizontal scroller, and every containment check passed because an ancestor scroller absorbed the overflow.
The only signal without looking at the page: the pane's box height was 650px and its clientHeight 635px. That 15px is a horizontal scrollbar.
Three things were too wide, all found by measuring against the pane's content edge:
.risk-matrix-wrapper |
a flex row β 140px info panel + 30px gap + 256px grid = 426px in a 336px pane. Stacked. |
.change-detail-header |
space-between with a nowrap action group. The buttons ran past the edge, and once they wrapped, Back sat centred against a two-row block and read as though Delete were lying on top of it. Now packs from the left and wraps as one group. |
.linked-incidents-table |
squeezed a prose subject. Card feed by Β§11 β and it is headerless already, so reading order was carrying the meaning anyway. |
I first matched the sticky header's -30px full-bleed to .changes-main's new 12px padding. Wrong box β its parent is .change-detail-content, whose 30px padding is what the -30px cancels. Setting -12px against a still-30px padding un-bled it and made the overflow worse, which is how the error announced itself.
Result: horizontal scrollbar 15px β 0px, scrollWidth === clientWidth, zero elements past the content edge, vertical scroll unaffected. Desktop at 1400px unchanged on every rule touched.
Reported twice, and the second time made it clear the first fix had not touched the real cause. The page scrolled the whole time. You could not see it happen.
.change-detail-sticky-header carries the title, the actions and the 11-item meta grid:
| bar height | pane height | ||
|---|---|---|---|
| 1200px | 388px | 592px | pins, content scrolls beneath, behaves |
| 360px | 818px | 590px | taller than the thing it is pinned inside |
A position: sticky element taller than its scrollport can never scroll away β it stays pinned over the whole visible area. Impact, Category and Requester were not the top of a stuck page; they were the stuck header, filling the screen.
Static below the breakpoint fixes it. Desktop keeps its pinned summary untouched.
Every measurement said the page scrolled, because it did:
pane.scrollHeight // 5332
pane.clientHeight // 590
pane.scrollTop = 500;
pane.scrollTop // 500 β works perfectlyscrollTop moves content that is entirely hidden behind a pinned block exactly as it moves content you can see. I was asserting the container could scroll, never that anything visible moved.
The check that finds it, now written up as Techniques Β§13:
const before = elementFromPoint(cx, cy); // detail-meta-label
pane.scrollTop = 500;
const after = elementFromPoint(cx, cy); // cab-review-section
// before !== after β THIS is what "the page scrolled" meansEd, editing a change on a phone: "can you put the tiny mce editor on its own sort of full screen panel β it's causing all sorts of things to spill off the side of the screen."
Two asks in one sentence, and the spill turned out to have its own cause.
The six rich-text fields β Description, Reason, Risk, Test plan, Rollback, PIR β share one widget behind a tab strip. It is a flex item, and a flex item defaults to min-width: auto: it will not shrink below its own min-content width, which here was the six tabs laid end to end.
.cm-rich-text-widget 578px β a flex ITEM that refuses to shrink
its flex parent 264px
.editor-form 292 / 292 β "contained", because IT is the scroller
TinyMCE then sized its editor to the 578px widget, .editor-form absorbed the difference, and every containment check passed. Two rules fixed it β min-width: 0 on the widget and the usual tab scroller β taking the widget to 264px and the form to scrollWidth === clientWidth. Written up as Techniques Β§14.
| docked | full screen | |
|---|---|---|
| widget | 264 Γ 337 | 360 Γ 640 β the viewport exactly |
| typing area | 300px | 544px |
Modelled on the Knowledge editor's pop-out (LAYER 17i) with one difference that matters: Knowledge has one field, this has six behind a strip, so the strip goes full screen too. Full screen on Description that could not reach Rollback would only have moved the problem. The bar names the field being edited, read live from the active tab; Close and the device back button are both ways out, because this tinymce.init has no fullscreen plugin and therefore no toolbar exit.
height: auto !important on .tox-tinymce is load-bearing β init({ height: 300 }) writes an inline pixel height, and a flex: 1 that does not beat it gives you a full-screen panel with a 300px typing area, which looks like the feature working.
tr() was not in scope. The sibling tr() helpers are nested inside their own module functions. Using one in initChangesMobile would have been a runtime ReferenceError that a parse check cannot see β the file parses perfectly.
The open button was a sibling of the widget. refreshFormLayout() re-parents the widget with host.appendChild(richTextWidget) so it follows whichever section anchors it, and sets display: none on it when nothing does. A sibling button would have been stranded in the section the widget left β or left offering full screen on a hidden widget. Bar and button now live inside the widget. The check is to call the relocating function yourself:
w.refreshFormLayout();
widget.contains(button) // true β a page that was only loaded never shows you thisThe first run reported no editor at all. showView('editor') shows the pane; editCurrentChange() is what calls initEditors(). I had reached the view by the shortest route that rendered it, and skipped the initialisation I came to measure β the same shape of error as Round 3, where the assertion sat next to the question.
w.showView('editor') β .tox-tinymce count = 0 β proves nothing
w.editCurrentChange() β 6 of 6 initialised β the real path
| Check | Result |
|---|---|
| Widget width at 360px | 578 β 264; .editor-form 292 / 292 |
| Tab strip | scrolls, 264 / 799 |
| Full-screen panel | 360 Γ 640, exactly the viewport; typing area 300 β 544 |
Survives refreshFormLayout()
|
β button still inside the widget and visible |
| Device back button | β exits; open button returns |
| Desktop @ 1200 and 1400px |
min-width: auto, position: static, tabs overflow-x: visible, no button, no bar, class never appears
|
mobile.js parse check |
β with a negative control |
Ed, still on the edit screen: "there is lots of padding around the panels β I think we only need a few pixels around them for it to look nice."
Measured from the viewport down to a single field:
.editor-form margin 20px 30px + padding 30px = 88px of 360
field input 264px wide
A quarter of the screen on empty space. The 30px is correct on a wide screen β the form is a card floating on the app background and a card needs to breathe. At 360px there is nothing for it to float against.
| before | after | |
|---|---|---|
.editor-form |
margin: 20px 30px Β· padding: 30px
|
margin: 8px Β· padding: 12px
|
.editor-header |
16px 30px |
12px |
.editor-footer |
12px 30px 16px |
10px 12px 12px |
| field width | 264px | 312px |
12px is what .change-detail-content already uses, so reading a change and editing one now sit on the same margin.
.editor-form, .editor-header, .editor-footer and .editor-scroll are not Change Management's. Both modules named their editor the same thing, and LAYER 17's bare rule
.editor-form { padding: 14px; } /* written for Knowledge */has been reaching this page all along β which is why the measured padding was 14px and not the 30px the module's own stylesheet declares.
So every rule in 23d is scoped to body.cm-editor-open, a class only change-management.js ever sets. That also means it beats the LAYER 17 rule on specificity (0,2,0 vs 0,1,0) rather than on load order, which is the more robust of the two.
π Before styling a bare
.editor-*/.detail-*/.list-*class, grep it across the other modules' CSS. A generic name is usually two modules' name. The tell here was benign β a padding that did not match the stylesheet β but the same shape of mistake going the other way would have silently restyled Knowledge.
The control that proves it: the same headless run loaded knowledge/index.php in a second iframe and asserted its .editor-form still reads margin: 0px; padding: 14px with body.cm-editor-open === false.
| Check | Result |
|---|---|
| Field width at 360px | 264 β 312px |
| Form contained | 344px wide, doc scrollWidth 360 |
| Genuine spills past the form's content edge |
zero β the .rich-text-tab hits are the swipe strip, an ancestor scrolls (the Round 1 false positive) |
| Full-screen panel still exact | β 360 Γ 640, typing area 544px, back button exits |
| Knowledge control, same run | β
margin: 0px, padding: 14px, cm-editor-open false |
| Desktop @ 1400px |
margin: 20px 30px, padding: 30px, header 16px 30px, zero spills |
| Looked at, not only measured | β screenshot |
Ed worked through the edit screen on a phone. Every one of these is a case of a rule that is correct in the place it was written.
.risk-scoring-row { align-items: flex-end; } /* Change Management: BOTTOM-align */
.editor-form .form-row { flex-direction: column; } /* LAYER 17: a KNOWLEDGE rule */align-items acts on the cross axis. The moment the second rule flipped the row into a column, the first one stopped meaning bottom and started meaning right β so all three shrank to their content and lined up on the right at 135 / 135 / 79px.
Neither rule is wrong on its own, and the module that owns each is unaware of the other.
π A property whose meaning depends on
flex-directionis a booby trap for any layer that restacks a row.align-items,justify-content,align-selfand themargin: autopush all swap axis with the direction. Grep for them before restacking someone else's row.
Now likelihood and impact two-up with the derived score as a bar beneath: 150 / 150 / 312.
Round 4 put the tabbed widget full screen, and Ed came back with "the tinymce editor is still causing a bit of havoc with the screen layout" β because six TinyMCE instances were still living in a 312px column, each an iframe with its own toolbar, sizing itself independently.
His suggestion, and it is the right shape: show the six fields as read-only cards, each with an Edit button that opens the full-screen editor.
| before | after | |
|---|---|---|
| TinyMCE instances in the form | 6 | 0 |
| while editing | 6 | 1 |
| fields visible at once | 1 (behind a swipe strip) | all 6, with excerpts |
The cards also answer a question the tab strip could not: which parts of this change are still empty? One card reads "Not provided" and you can see it without tapping anything.
function getEditorContent(id) {
const editor = tinymce.get(id);
return editor ? editor.getContent() : ''; // β '' when nothing is initialised
}saveChange() reads all six fields through that, and editorsReady is set but never read β nothing guards it. Simply not initialising the editors would have made Save silently blank all six fields.
So on mobile the textarea becomes the source of truth, and four accessors are wrapped:
| wrapped | why |
|---|---|
getEditorContent |
reads the textarea when no editor is live β this is the one that prevents the data loss |
setEditorContent |
writes the textarea, so loading a change still fills the fields |
initEditors |
skips TinyMCE and clears the textareas itself |
destroyEditors |
closes the panel first, so Cancel mid-edit cannot strand it |
That third one is not obvious. The create path clears through tinymce.get(id) directly rather than through setEditorContent:
initEditors(() => { editorIds.forEach(id => { const e = tinymce.get(id); if (e) e.setContent(''); }); });With no editors that clears nothing, so a new change would have opened pre-filled with the last one's text. The wrapper clears the textareas itself, which covers both callers.
β οΈ editorIdsis a top-levelconstand therefore not a property of the global object β unlike the four functions, which arefunctiondeclarations and are. The field list is read from the DOM instead. Same trap as theletat script top level note.
No sanitiser needed: the card excerpt is assigned through textContent, so no author-written HTML is ever parsed into the page. And the labels reuse change-management.detail.edit / .not_provided / common.close β the detail view already says exactly those words about exactly these fields, so a phone borrows them rather than adding three strings to 24 locales.
Work start / Work end / Outage start / Outage end are data-width="half" β 146px to show a date, a time and a picker icon.
β οΈ No probe saw this. The parts of an<input type="datetime-local">live in shadow DOM and overflow inside the control without ever changing itsscrollWidth. It measured 144 / 144 β perfectly contained β while reading as clipped. Same family as the crushed table in Β§11: containment is not legibility.
Ed asked for the time on a line below the date. That cannot be done. The only styling hooks for the parts of a datetime-local are ::-webkit-datetime-edit-*, which are WebKit/Blink-only, and iOS Safari renders the control as a native spinner that ignores them entirely β a rule that "worked" in headless Chrome would have done nothing on the phone the bug came from. Full width gives 312px, which shows 23/02/2026 21:06 in full, which is the outcome that was actually wanted.
:has(input[type="datetime-local"]) rather than the four field keys, so a date field added through Form fields settings is covered without another rule.
.search-btn { width: 100%; } /* its ONLY rule in change-management.css */So the phone drew the browser's default: grey rgb(107,107,107), square corners, 13.3px text, 19px tall β under half a tap target, sitting beside a properly drawn teal one. Problem Management styles the same shared class correctly in its own page, so the fix borrows that shape rather than inventing a third look, and stays an outline button: two solid teal buttons side by side would both read as "the" action.
π And
.btn-full { width: 100% }had never applied to New change either..btnsets nodisplay, so<a class="btn btn-full">is an inline box β and width does not apply to inline boxes. It had been content-width all along, sitting at the left of its grid cell, which is exactly the gap Ed could see.display: inline-blockis what lets both the width reset and the 44px tap height take effect.
iOS zooms into any focused field under 16px and leaves the page zoomed. .search-modal is shared by Change Management, Problem Management and Tickets, and mobile.css had no rule for it at all β LAYER 3's anti-zoom only knows .modal-content, and this is another module-owned class it cannot see.
All three were zooming for the identical reason, so all three were fixed rather than scoping to the one that got reported.
Section 23e had precisely zero measured effect β the Watchtower signal. It was not load order this time. My explanatory comment contained a nested comment terminator:
/* ...
.risk-scoring-row { align-items: flex-end; } /* = BOTTOM-align */
...the rest of the comment is now CSS... */CSS comments do not nest. That inner */ closed the comment early, and the parser consumed the remaining prose β and the rules after it β while recovering. A brace count cannot see it, so the checks now include a comment-nesting scan:
awk '{ n=gsub(/\/\*/,"",$0); m=gsub(/\*\//,"",$0);
if (c && n) print "NESTED /* line " NR;
c += n-m; if (c<0) c=0; if (c>1) c=1 }' mobile.css| Check | Result |
|---|---|
| TinyMCE instances in the form at 360px | 6 β 0; exactly 1 while editing |
getEditorContent with no editor live |
156 chars β the data-loss trap closed |
| Captured save payload | all 5 populated fields intact, the empty one empty |
| Create path | all 6 textareas cleared, all 6 cards read "Not provided" |
| A field hidden in Form fields settings | 5 cards, PIR absent |
cancelEdit() mid-edit |
panel closes, instance removed, back to detail |
| Risk row | 150 / 150 / 312, no longer right-hugging |
| Date fields | 146 β 312px, 23/02/2026 21:06 in full |
| Buttons | Search 19 β 44px and styled; New change ends at 348 = the exact content edge |
| Search modal fields under 16px | 0 in Change Management, Problem Management and Tickets |
| Desktop @ 1400px | 6 instances, 0 cards, tab strip flex, risk row 317Γ3, dates two-up at 486, buttons untouched |
| Looked at, not only measured | β screenshots of the cards, the schedule and the sidebar |
- The Search button is unstyled on desktop too β the same 19px grey system button at 1400px. A real defect, but a desktop change sits outside this rollout's "desktop byte-identical" contract, so it is flagged rather than fixed here.
-
refreshFormLayout()callsswitchTab(firstVisible)when the active tab is hidden, andswitchTabreads the implicit globalevent. Pre-existing, harmless to the cards (they do not read.active), untouched.
- MobileβFriendly β the overview and the rollout state
- Mobile: Techniques & Tricks β the codeβlevel catalogue
- Mobile: Problem Management β the layer this one mirrors
- Change Management β the module itself
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
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- 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)