-
Notifications
You must be signed in to change notification settings - Fork 15
Mobile Friendly Techniques
A codeβlevel catalogue of the CSS and JavaScript techniques behind FreeITSM's mobile experience β the reusable moves, the nonβobvious ones, and the iOS traps that shaped them. If MobileβFriendly is the strategy and Mobile: Tickets is what was built, this page is how it works.
Everything here lives inside the one @media (max-width: 768px) block in assets/css/mobile.css (organised into numbered LAYERS) and the one matchMediaβgated IIFE in assets/js/mobile.js. Above 768px none of it exists, so desktop stays byteβidentical β that is the one hard rule.
- Wrap, don't edit β extending a page's JS from outside
- β Independent horizontal scroll per message (Shadow DOM)
- The iOS "reflow to desktop" trap β the metaβlesson
- Section sheets β crowded panels get their own screen
- The sticky dayβheading (and its peekβthrough trap)
- Masterβdetail + the device back button
- Reflow & polish patterns
- Verifying without a device β headless rendering
mobile.js never touches the 5,000βline inbox.js. It loads after it, and because both are classic (nonβmodule) scripts they share the global lexical environment β so mobile.js can read the page's state and wrap its functions from the outside.
Read the page's state by bare identifier (guarded, because it may not be a global on every page):
function getCurrentEmail() {
return (typeof currentEmail !== 'undefined') ? currentEmail : null;
}
// same trick for API_BASE, formatFullDateTime, ticketAttachments β¦Wrap a global to add a mobile branch, leaving the desktop path exactly as it was:
if (typeof window.showAuditHistory === 'function') {
var _orig = window.showAuditHistory;
window.showAuditHistory = function () {
if (mq.matches) { openAuditSheet(); return; } // phone: our own sheet
return _orig.apply(this, arguments); // desktop: untouched
};
}The same move wraps selectEmail / selectFolder (to drive the pane stack), renderAttachmentInfoBar (to refresh a compact badge), and syncPopoutToTicketState (to strip a desktopβonly body class on mobile). The renderer is never edited, so desktop can't regress.
The one justified
inbox.jsedit in the whole effort was a single TinyMCEcontent_stylestring β CSS genuinely can't reach inside the editor's iframe β and even that is gated on@media (pointer: coarse).
The problem. Email bodies are rendered inside a shadow root so a thirdβparty email's CSS displays exactly as its author intended, sealed off from the app. A marketing email built to a fixed 680px table is therefore wider than a phone. Desktop clips that overflow so it can't overlap the panels below; on a phone that clipping meant the rightβhand half of such an email simply vanished off the edge with no way to reach it.
The insight. Each message is its own shadow host in the light DOM. Overflow set on the host governs the shadow content β so you can turn each message into its own horizontal scroll container without touching the sealed email CSS.
// inbox.js β every message body is a shadow host
function hydrateEmailBodies(root) {
root.querySelectorAll('[data-email-body]').forEach(host => {
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = '<style>*{box-sizing:content-box}</style>' + bodyHtml;
});
}/* mobile.css β LAYER 12. The fix is on the light-DOM host, outside the shadow. */
.email-body-content,
.thread-message-body {
overflow-x: auto; /* desktop had overflow:hidden β clip β scroll */
-webkit-overflow-scrolling: touch; /* momentum scroll on iOS */
overscroll-behavior-x: contain; /* swipe doesn't chain to the page / back-nav */
}Why it's clean:
- The sealed CSS is untouched. The rule targets the host element, never anything inside the shadow tree.
-
Only that message scrolls sideways.
overflow-ystayshidden(inherited from the desktop rule) so vertical containment is preserved; the thread, the reading pane and the page never scroll horizontally. - Replies written in FreeITSM are unaffected β they already wrap to fit, so there's nothing to scroll.
The result is that different sections of one ticket thread scroll horizontally independently β a wide newsletter swipes sideways in its own box while the plainβtext reply beneath it sits still. See Β§8 for how this was proven (the page must not widen β document.scrollWidth === innerWidth).
This is the single most important thing to understand about mobile Safari here, because three separate bugs were all the same bug.
iOS sizes the layout viewport to fit its widest content. Anything wider than the screen makes Safari reflow the whole page to a desktop width β the
max-width: 768pxmedia query stops matching β every mobile rule switches off β the layout snaps back to the desktop one (a centred modal box, cramped columns). It looks like "mobile mode broke."
So the governing rule is: nothing in a mobile view may be wider than the screen. The trap sprung three ways:
| What was too wide | Symptom | Fix |
|---|---|---|
A form field with font-size < 16px
|
iOS zooms on focus β sheet spills wide β reflow |
16px on every focusable field (@media (pointer: coarse) for iframe editors) |
| The audit history's 5βcolumn table | Modal reverts to a centred desktop box | Don't render a wide table on a phone β render a narrow card feed in a sheet |
| A fixedβwidth external email | Rightβhand content unreachable | Contain it in a perβmessage scroll box (Β§2) |
The lesson that ties them together: on mobile you don't clip and hope β you either reflow the content to fit or give it its own bounded scroll container. A wide thing left loose in the layout doesn't just overflow, it detonates the whole media query.
Crowded readingβpane sections (Links, Properties, Time, Objects) each get their own fullβscreen sheet β a position: fixed; inset: 0 panel β opened from the action toolbar. It's configβdriven, so a section is one array entry:
var SECTIONS = [
{ cls:'links', title:'Links', icon:'π', sel:'.problem-strip', all:true },
{ cls:'props', title:'Properties', icon:'β', sel:'#ticketPropertiesContainer', all:false },
{ cls:'time', title:'Time', icon:'β±', sel:'#timeEntriesContainer', all:false },
{ cls:'cmdb', title:'Objects', icon:'π₯', sel:'#cmdbObjectsContainer', all:false }
];For each entry, mobile.js builds a sheet, relocates the section's DOM node into it (keeping its id so async loaders still find it), and adds a toolbar button. Because the reading pane reβrenders on every ticket open, the relocation reβruns after each render, idempotently.
Audit history is the exception that proves the pattern. It isn't in the reading pane to relocate, so instead of moving a node it fetches on demand and renders into the same sheet chrome:
function openAuditSheet() {
auditSheet.style.display = 'flex';
fetch(API_BASE + 'get_ticket_audit.php?ticket_id=' + email.ticket_id)
.then(r => r.json())
.then(d => renderAuditFeed(d.audit)); // day-grouped cards, never a table
}That single decision β feed, not table β is also what defuses the reflow trap for audit.
The audit feed groups entries by day with a sticky date bar. Two nonβobvious tricks made it behave.
The peekβthrough trap. A scroll container with padding-top makes position: sticky; top: 0 stick to the contentβbox top β i.e. below that padding β leaving a transparent strip under the header where scrolling rows show through above the stuck date. Fix: zero the scroll body's top padding so the date tucks flush, and let the date's own opaque padding be the only gap.
.mobile-sheet-audit .ms-body { padding-top: 0; } /* kill the peek-through strip */Fullβbleed shaded bar. The heading node is a <span>, so it needs display: block or the shade shrinkβwraps the text. To run edgeβtoβedge inside a padded container without widening it, cancel the container's side padding with equal negative margins, then reβinset the text with matching padding:
.ma-day {
display: block; /* a <span> won't fill width otherwise */
position: sticky; top: 0;
margin: 0 -16px; /* negative margin == .ms-body padding */
padding: 9px 16px 7px; /* re-inset the text to line up with rows */
background: var(--surface-hover);
border-bottom: 1px solid var(--border);
}Because the margins exactly cancel the padding, the bar reaches both edges and can't add horizontal overflow β which, per Β§3, matters more than it looks.
The desktop threeβpane split (folders / list / conversation) becomes a oneβpaneβatβaβtime slide stack. State is a single attribute on <body>, and the panes are slid with transform via CSS ancestor selectors:
body[data-mobile-pane="reading"] .email-list-container { transform: translateX(-100%); }
body[data-mobile-pane="reading"] .reading-pane { transform: translateX(0); }The move that makes it feel native rather than like a resized website: wire each inβpane navigation to history.pushState, and restore the pane on popstate β so the device back button pops the stack.
function pushPane(p) {
setPane(p);
if (mq.matches) history.pushState({ nmPane: p }, '');
}
window.addEventListener('popstate', function (e) {
if (mq.matches) setPane((e.state && e.state.nmPane) ? e.state.nmPane : 'list');
});Small, repeatable moves applied across the section sheets. Together they're the difference between "shrunk desktop" and "designed for a thumb."
Wrap a desktop singleβrow form. The timeβentry form (minutes + notes + Add) ran off the edge. flex-wrap plus a fullβwidth action row fixes it with no markup change:
.mobile-sheet-time .time-entry-form { flex-wrap: wrap; }
.mobile-sheet-time .time-entry-add-btn { flex-basis: 100%; } /* Add drops to its own row */Strip nestedβcard chrome. Panels built as cards (border + shadow + padding + margin) are redundant inside a sheet that's already a panel β flatten them so content sits flush:
.mobile-sheet-cmdb .cmdb-section { border: none; border-radius: 0; padding: 0; margin: 0; background: none; }Kill :hover transforms on touch. A liftβonβhover (translateY + shadow) sticks after a tap on a touchscreen and reads as jank. Remove it on the sheet:
.mobile-sheet-cmdb .cmdb-link-card:hover { transform: none; box-shadow: none; }Darkβmode remap, scoped to the sheet. Where a panel is authored in hardcoded light colours that glare in dark mode, remap them under [data-theme="dark"] β scoped to the sheet so desktop and light mode are untouched, and following the theming rule that a midβtone brand accent must be lifted to a lighter tint to stay legible on a dark ground:
[data-theme="dark"] .mobile-sheet-cmdb .cmdb-link-card { background: #271e25; border-color: #45293a; }
[data-theme="dark"] .mobile-sheet-cmdb .cmdb-section-head h3 { color: #f472b6; } /* lifted accent */One heading per sheet. Each sheet's own .ms-head title is the single heading; the relocated panel's inner heading is hidden so the title isn't said twice (a white "Links" over a purple "Links"):
.ms-body .problem-strip-label { display: none; } /* Links */
.mobile-sheet-cmdb .cmdb-section-head h3 { display: none; } /* Objects */Fingerβfriendly chrome. The corner β becomes a blue Close button; bare textβlink actions become pills; every tap target clears ~40px:
.ms-close {
min-height: 38px; padding: 8px 18px; border-radius: 8px;
background: var(--accent); color: var(--on-accent);
font-size: 15px; font-weight: 600; border: none;
}There's no headless browser in the normal loop and the inbox needs auth, so onβdevice testing with a human is the real proof. But a lot can be checked first by driving headless Chrome against the live CSS/JS on localhost.
Screenshot a sheet at phone width. Headless Chrome on Windows won't make a window narrower than ~500px, so a --window-size=390 screenshot is really a 500px viewport clipped to 390 β content looks like it overflows when it's wrapping fine. Two ways round it: pin the element to a phone width in the test markup, and measure innerWidth rather than trusting the clipped image.
// what a wide email must NOT do: widen the page
out.textContent = 'docScrollW=' + document.documentElement.scrollWidth +
' innerW=' + window.innerWidth; // these must be equalProve the Shadow DOM scroll containment for real. The horizontalβscroll fix was verified with an actual attachShadow host holding a 680px table: the host reported scrollWidth 721 > width 300 with overflow-x: auto (it scrolls inside), while document.scrollWidth === innerWidth (the page did not widen). A screenshot alone couldn't have shown that.
Harness gotcha: don't reuse the real
.reading-paneclass in a test wrapper β it inherits the app's positioning and faked a 720px document width, sending an early diagnosis completely wrong. Use a plain<div>with an explicit width.
Parseβcheck injected JS by loading mobile.js in a throwaway page with window.onerror writing to document.title, then dumping the DOM β a syntax error surfaces as an ERR: title without needing Node.
| Layer | Trick |
|---|---|
mobile.css LAYER 1β9 |
App shell, masterβdetail, modalβsheet, density, bottom bar, collapsible header, section sheets, openedβticket refinements, AskβAI panel β see Mobile: Tickets |
| LAYER 10 | Audit history sheet + dayβgrouped feed (Β§4, Β§5) |
| LAYER 11 | Timeβentries reflow (Β§7) |
| LAYER 12 | Independent horizontal scroll for wide email (Β§2) |
| LAYER 13 | Affectedβobjects (CMDB) polish + dark remap (Β§7) |
| LAYER 14β15 | Assets β twoβpane stack, card feeds, the shared .dt-* table view, dashboard, settings, servers β see Mobile: Assets
|
| LAYER 16 | Calendar β month pills β dots + a tappedβday agenda, week as a contained scroller, popup β bottom sheet β see Mobile: Calendar |
| LAYER 17 | Knowledge β search relocated into the subβbar, authorβwritten article HTML contained, the editor's localStorage popβout neutralised β see Mobile: Knowledge |
| LAYER 18 | Service Status β twoβup service board, incidents as a card feed β see Mobile: Service Status. No mobile.js branch: not every module needs one. |
-
β οΈ A rule that "already covers this" is worth measuring. LAYER 3's 16px antiβzoom rule (.modal-content .form-input, specificity0,2,0) had never applied to the typed inputs, becauseinbox.cssstyles them as.modal .form-group input[type="date"](0,3,1) at 14px β so every native date/time/number field and every<select>in every modal stayed at 14px on a phone, springing Β§3 on focus. Fixed in #999 by mirroring the inbox selector list exactly, so specificity ties andmobile.csswins on load order. Found by measuring computed styles, not by reading the CSS. -
A JSβpositioned element can often be reβpositioned in pure CSS. Author
!importantbeats an inline style, so the calendar's clickβpositioned quickβview popup became a bottom sheet with no JS wrap at all βtop: auto !important; bottom: 0 !important; left: 0 !important; right: 0 !important. The knowledge Share dropdown took the same treatment. -
β οΈ A wide table that "fits" hasn't been fixed β it has been crushed.display: block; overflow-x: autoon a table is only half the rule; withoutwhite-space: nowrapon the cells the browser wraps every column down to one word per line, which passes thedocScrollW === innerWidthcheck and is unreadable. Containment is the floor, not the finish line β the same lesson the Calendar settings page taught from the other direction. -
β οΈ The sticky peekβthrough has now happened in two unrelated modules. Β§5 found it on the audit sheet; the knowledge article's sticky title hit it again for the identical reason β a sticky element sticks to the top of its scroll container's content box, i.e. below itspadding-top, leaving a transparent strip that scrolling text slides through. If a module has aposition: stickyheader, check what padding its scroll container carries before looking anywhere else. Don't delete the space: move it onto something that scrolls away (a margin on the row above), and make any negative margin match the card's padding exactly β an overβlong one lifts the bar clear and opens a strip of its own. -
A collapsible summary should BE the tap target. The knowledge article's meta block (author / created / modified / views) collapses to one line, and the whole row is the control rather than a chevron beside it: a much bigger target, and its accessible name is the visible text β so it needs no
aria-label, and therefore no new string in 24 languages. Draw the chevron with borders, not aβΎglyph; at 11px the character renders as a faint dot in the app's font stack. -
β οΈ When you "normalise" a layout, state BOTH insets in one place. Aligning a modal heading with its fields meant zeroing.modal-content's padding and giving the body 16px β and leaving the header to LAYER 3's plain.modal-header. That rule doesn't always win:inbox.csscarries.modal-content:has(> .modal-body) > .modal-headerat 24px, which is (0,3,0) and beats it. Only the modals that had a.modal-bodymatched, so three dialogs lined up and the fourth sat 8px further in β a near-miss that reads as a one-page quirk until you find the selector. Setting both insets in the same block is what makes "normalise" actually normalise. -
β οΈ Measure where the TEXT starts, not where the box starts. Comparing a heading's alignment with a field bygetBoundingClientRect().leftcompares a full-width<div>against an inset<label>β it measures the padding, not the misalignment, and reported a consistent 14px error on four pages that were mostly fine. Add each element's ownpadding-leftbefore comparing. -
A shared class means a one-page fix is a five-page fix.
.tab-contentis styled once ininbox.csswithpadding: 30pxβ a sixth of a 360px screen. Trimming it for one module's settings screen improved all five, which is the payoff for keeping module rules keyed on shared classes rather than page ids. -
β οΈ Read a page's own CSS comments before opting it in.tickets/settings/states outright that<body>must not be flex β a flex body turned extensionβinjected nodes (LastPass) into flex items and wrecked the layout β and builds its own.settings-shellinstead. LAYER 2 makes<body>a100dvhflex column on every optedβin page, so opting it in blind reβcreates a bug somebody already diagnosed. The escape hatch is a marker:body[data-mobile-shell="own"] { display: block; height: auto }. No harness would have caught this β a headless run has no browser extensions. -
β οΈ A page that renders one tab at a time is 1/N verified. Measuringtickets/settings/as loaded reported clean; walking all 18 tabs and measuring each found underβsized fields on two of them and crushed grids on another. Drive the tabs. -
Exclude what must not change, don't enumerate what must. The settings antiβzoom rule listed six input types and missed a
datetime-localsitting at the browser default. Rewritten asinput:not([type=checkbox]):not([type=radio])β¦, a newly added field type is covered by default instead of forgotten. -
β οΈ Stacking a grid to one column does nothing if its childrengrid-column: span 2. A child spanning two columns of a oneβcolumn grid makes the browser create an implicit second column, so the layout stays twoβup and the rule looks like it simply failed. The tell is that the tracks come out unequal (measured 93px + 111px where1fr 1frwould be even). Neutralise the spans too. -
Ask the question measurement can answer. "Contained but crushed" kept slipping past overflow checks β a twoβcolumn grid at 360px doesn't overflow, it just squeezes each column to 150px. Rather than keep relying on screenshots, assert it directly: nothing inside the container may still be laid out in more than one column at phone width.
-
β οΈ position: stickydoes not pin anything to the bottom of a SHORT container. Sticky only stops an element scrolling away β with less content than screen it just sits where the content ends, halfway up. Pair it withmargin-top: autoin a flex column: auto-margin handles the short case, sticky the long one.β οΈ And the two fight a negativemargin-bottomβ auto absorbs whatever free space the negative margin creates, so they cancel and the bar lands its own margin short of the edge (measured: 14px). Drop the container's bottom padding instead.margin-top: automust also come after anymarginshorthand. -
β οΈ Never mixgetBoundingClientRect()withclientHeight/offsetTopon anything inside a transformed container. Rects are in scaled coordinates, layout properties are not βrect.top + el.clientHeightproduced a "bottom" 11px beyond a 760px viewport and sent a diagnosis completely wrong. Compare rect to rect, or computed style to computed style, never one of each. (Modals that animate in with atransformmake this a routine hazard: it has now caused four false alarms.) -
β οΈ An inlinestyle.displayset by the page's own JS beats your rule. Service Status doestable.style.display = 'table'every time it renders incidents, so the cardβfeed conversion neededdisplay: block !importantβ without it the feed works until the first refresh and then silently reverts. Grep a module forstyle.displaybefore converting one of its tables. -
"It reflows" is not the same as "it works". The service board was already an
auto-fill, minmax(200px, 1fr)grid β technically responsive, and at 360px a single column, i.e. a list. A status board exists to be scanned, so density beats card size: dropping the floor to 150px got two up and halved the scroll. Ask what the component is for before accepting that it already reflows. -
Not every module needs a
mobile.jsbranch. Four in a row had one, which made a fifth feel inevitable; Service Status has no sidebar, no pane stack and nothing worth wrapping, so the shared shell was the whole of its behaviour and the module shipped as CSS only. -
β οΈ A CSS comment edit is a code edit. Appending a paragraph to an existing comment and closing it early orphans text outside the block, and the parser then drops the rule that follows. It happened twice in one session; both times the braceβbalance and columnβ0 checks passed happily and only the assertions caught it. -
β οΈ A JSβmounted widget injects its stylesheet at RUNTIME, so it beats yours on source order whatever the specificity. TinyMCE puts its skin CSS into<head>when the editor mounts β always aftermobile.css. Rules aimed at.tox-*need!importantor they silently lose. The tell is a fix that looks like it half worked: hiding the upsell badge (adisplayrule that happened not to collide) succeeded whileflex-wrap: nowrapon the menubar did nothing, which read as "mostly fixed" until it was measured. When a rule aimed at a mounted widget appears to partly work, measure which half. -
Reclaim a thirdβparty widget's own chrome before adding your own. In TinyMCE at 360px the menubar wrapped to four rows and an upsell badge sat beside it β ~130px of a 720px screen gone before a word could be typed. Scrolling the menubar sideways (rather than hiding it, which would have taken Table and Format with it) plus dropping the badge returned 78px, measured as typingβarea height.
-
Build your own full screen, don't borrow the widget's. TinyMCE's
fullscreenplugin was loaded and one line away, but this init's toolbar has no fullscreen button and its only other exit is the View menu β a phone user could get stuck in it. Aposition: fixed; inset: 0overlay with your own alwaysβvisible Close, plus ahistory.pushStateso the device back button exits, is a dozen lines and cannot trap anyone. -
Harvest a translated word, don't write one.
common.backwas needed for a shortened mobile button. Rather than an ENβonly key that falls back silently in 23 locales, each locale'scommon.phptook its own existing translation of the same word fromchange-management.php. Zero invention, zero fallback, and the generic word ends up incommonwhere the next module finds it. Worth checking for before assuming a new string is unavoidable. -
β οΈ Grep a module forlocalStoragebefore bringing it along. Two of the four modules so far have carried a saved DESKTOP mode that reβapplies itself on a phone β the tickets ticketβpopout (#762) and the knowledge editor popβout (#1000), which forces a fixed 340px property panel onto a 360px screen. Neither is visible in the markup, both look like the page is broken for one user and fine for everyone else, and the fix is the same: wrap the function that applies it, leave the stored preference alone, keep a CSS backstop. -
CSS:
assets/css/mobile.cssβ one@media (max-width: 768px)block, LAYERS 1β16. -
JS:
assets/js/mobile.jsβ onematchMediaβgated IIFE. -
Related: MobileβFriendly (strategy & iOS lessons), Mobile: Tickets (what was built), Mobile: Calendar, Theming & Dark Mode.
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
- 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)