Skip to content

Mobile Friendly Assets

Ed Mozley edited this page Jul 29, 2026 · 5 revisions

Mobile: Assets

Assets is the second module made mobile‑friendly, after Tickets. It follows the same patterns and the same hard rule described in Mobile‑Friendly: everything lives inside one @media (max-width: 768px) block and one matchMedia‑gated script, so desktop is byte‑identical.

🧰 For the code‑level catalogue of the CSS/JS tricks used here β€” the iOS reflow trap, contained horizontal scrollers, wrap‑don't‑edit, headless verification β€” see Mobile: Techniques & Tricks.

Why Assets, and why next? Asset QR labels exist so you can stick a code on a laptop, scan it with a phone and update the thing where it stands. That journey is worthless if the module behind it is a desktop screen shrunk to 360px.

Shipped in two rounds: #936 (the list and the asset itself) and #937 (everything wide β€” the tabs, the trails, and the module's other four pages).


Scope

Page State
asset-management/ β€” the asset list + detail Done deep β€” master‑detail stack, reflowed detail, card‑feed tabs
asset-management/table.php β€” full‑screen table view Done β€” contained horizontal scroller + stacked toolbar
asset-management/dashboard/ Done β€” one chart per row
asset-management/settings/ Done β€” scrolling tab strip, full‑width fields
asset-management/servers/ Done β€” 2‑up cards, contained table, full‑screen detail sheet
asset-management/scan.php, scanner.php, assign-tags.php Not part of this pattern, deliberately. These are standalone mobile‑first surfaces with no desktop version to protect, so they carry their own small stylesheets rather than desktop CSS plus a mobile.css override β€” loading mobile.css into them would apply the inbox's pane‑stack rules and clip them. Why the module runs two mobile strategies at once, and where they meet, is traced in Asset scanning β€” Developer Guide Β§7.
asset-management/dashboard/library.php, labels.php, assign-tags.php, help.php Shell only β€” reachable, bodies not reflowed

What's been done

Round 1 (#936) β€” the list and the asset

  • Master‑detail stack. Assets is two panes, not three (list ↔ detail; there is no folder tree), so the stack has two states and needs no Folders button. Tap an asset β†’ the detail slides in full‑screen; β€Ή Back and the device back button return to the list.
  • Sub‑bar carrying β€Ή Back on the left and the asset's name on the right, so you can still see what you're looking at once the list has slid away.
  • One‑column info grid. The desktop 4‑column label/value grid is unreadable at 360px and still truncates at two columns.
  • 16px on every editable field (the iOS anti‑zoom rule β€” see the trap), plus -webkit-appearance: none on date inputs so they don't spill.
  • Scrolling tab strip, wrapping action buttons (History / Custody / Print label), roomier list rows and a stacked batch‑select bar.
  • App shell β€” waffle drawer for modules, ☰ hamburger for views, company switcher moved into the waffle drawer.

Round 2 (#937) β€” everything wide

  • Devices and Software tabs stop being 4‑ and 3‑column tables and become a card feed: name first and prominent, supporting values beneath, status as a badge on its own line. The device‑class grouping row becomes a full‑bleed shaded heading.
  • History and Custody trails get the same treatment. These live in modals, which are already full‑screen sheets β€” but a full‑screen sheet containing a 5‑column table still spills, which was exactly the #855 audit‑history bug. The table had to stop being wide; the sheet was never the problem.
  • Table view (table.php) keeps its table and gets a contained horizontal scroller instead β€” see the two techniques for why. Search box, buttons and count stack for a thumb.
  • Dashboard β€” one widget per row, stacked toolbar, chart heights capped.
  • Settings β€” side‑scrolling tab strip, full‑width 16px fields, thumb‑sized buttons, contained tables.
  • Servers β€” 2‑up summary cards, side‑scrolling filter chips, contained inventory table, and the hand‑rolled server detail modal promoted to a full‑screen sheet (it isn't a .modal-content, so LAYER 3 doesn't reach it).
  • The app shell now reaches all of them. Previously only the inbox and the asset list injected the views hamburger and moved the company switcher; that is now a shared step every opted‑in page runs.

Two techniques, and how to choose

Round 2 is mostly one decision applied five times: a wide table either stops being a table, or gets a scroller that can't leak. Which one depends on whether you know the columns.

When What Why
Card feed The columns are known and fixed β€” Devices, Software, History, Custody The table becomes display: block; thead is hidden; each row is a card with a heading line and supporting lines Nothing is laid out side by side, so nothing can spill at any width
Contained scroller The columns are unknown β€” the table view's user‑chosen column set, the servers inventory Table kept; the wrapper gets overflow-x: auto; overscroll-behavior-x: contain The labels live in the header row, so unlabelled cards would be a stack of values with nothing to say what they are

No CSS‑generated column labels

The usual card‑feed recipe puts the column name back with td::before { content: "Publisher" }. Don't, here: Assets is translated into 21 languages and a CSS content string is hardcoded English. The reading order carries the meaning instead β€” name first and prominent, everything else subordinate beneath it:

/* the name is the card's heading */
.software-table tbody td:first-child { display: block; font-size: 15px; font-weight: 600; }
/* publisher Β· version share one line, separated rather than stacked */
.software-table tbody td:nth-child(3)::before { content: " Β· "; }

The History and Custody trails render the same .history-table with different column counts (4 and 5), so nothing there may key on a column index. It keys on the renderers' own .history-meta class instead: small print (date, due‑back, analyst) goes inline-block, everything else is a block β€” which pairs adjacent meta cells onto one line and lets the blocks between them break it.


The trap everything here is dodging

On iOS, content that spills off the side makes Safari reflow the whole page to a desktop width. At that point max-width: 768px stops matching, the entire mobile stylesheet switches off, and you get the desktop layout on a phone β€” the worst of both. It looks like "the mobile CSS didn't load", so it sends you chasing the wrong thing.

Three separate bugs in the tickets rollout were this one trap (a <16px field triggering zoom, the wide audit table, a fixed‑width external email). Every wide‑thing decision on this page exists to keep it from happening: fixing the width is what keeps the phone layout alive. Full write‑up in Techniques & Tricks Β§3.


How it works

mobile.js is one IIFE. Round 2 restructured its entry point so the shell runs first and the page‑specific work is a branch beneath it:

injectViewsHamburger();      // shared: .header-nav -> right drawer
moveTenantIntoWaffle();      // shared: company switcher -> waffle drawer

if (document.querySelector('.assets-container')) { initAssetsMobile(); return; }

// Flat pages (table view / dashboard / settings / servers): the shell is all they need.
if (!mc || !document.querySelector('.email-list-container')) return;

// ...tickets-inbox wiring below

Two things worth noting about that guard:

  • The test is not just !mc. The servers page carries .main-container (as .servers-container) but has no email list, and letting it fall through would inject a Folders button onto a flat page.
  • injectViewsHamburger() and moveTenantIntoWaffle() were extracted as shared functions in round 1 when Assets became the second caller β€” extract on the second use, not the third.

Wrapping selectAsset

Same wrap‑don't‑edit rule as the inbox β€” the module's renderer is never touched:

var _selectAsset = window.selectAsset;
window.selectAsset = function (assetId) {
    var r = _selectAsset.apply(this, arguments);
    // Only when genuinely navigating list -> detail. selectAsset is also called
    // to re-render in place, and those must not stack history entries.
    if (mq.matches && currentPane() !== 'detail') pushPane('detail');
    ...
};

mobile.css layers

Layer Purpose
1–13 Tickets β€” see Mobile: Tickets
14 Assets round 1 β€” two‑pane master‑detail stack, list density, detail‑pane reflow, 16px fields, scrolling tabs
15 Assets round 2 β€” 15a Devices/Software card feed Β· 15b History/Custody card feed Β· 15c the shared data‑table view Β· 15d dashboard Β· 15e settings Β· 15f servers

15c is named for the shared data‑table engine (.dt-*) rather than for Assets, so any other module's table view inherits the treatment the moment it links mobile.css.

Opting a page in

    </style>
    <link rel="stylesheet" href="../../assets/css/mobile.css?v=31">   <!-- AFTER the page's own <style> -->
</head>
...
    <script src="../../assets/js/mobile.js?v=14"></script>            <!-- last -->
</body>

The settings page additionally marks itself, because .container is far too common a class name to restyle globally:

<body data-mobile-page="settings">

⚠️ mobile.css and mobile.js are shared. Bump the ?v= on every page that links them, not just the one you edited, or a page will run the stale file against the new one.


Challenges & solutions

Challenge Solution
Four columns of free text don't fit 360px, and a spilling table takes the whole mobile layout down with it. The table stops being a table β€” block rows, each a card. Nothing side by side, nothing to spill.
A card feed normally re‑labels each value with CSS content β€” but that string is hardcoded English and Assets ships in 21 languages. No generated labels. Reading order carries the meaning: name prominent, supporting values subordinate.
History and Custody render the same table class with different column counts, so index‑based rules would mis‑style one of them. Key on the renderer's own .history-meta class, not on :nth-child.
The table view's columns are user‑chosen, so cards would be unlabelled values. Keep the table, contain the scroll: overflow-x: auto; overscroll-behavior-x: contain on .dt-wrap, white-space: nowrap on the cells so rows stay readable inside it.
A device with no status rendered a bare - on its own line, reading as a stray dash. td:nth-child(4):not(:has(.device-status)) { display: none }. A browser that doesn't understand :has() drops the rule and shows the dash β€” exactly what it showed before, so the fallback is a no‑op rather than a break.
The settings page overflowed to 776px at a 360px viewport and nothing inside it would contain itself. Root cause: LAYER 2 makes <body> a flex column, and a flex item's automatic minimum size is its content width β€” so the wide tab strip stretched .container itself, and the scrollers inside were sized against an already‑too‑wide parent. Fix is on the parent: width: 100%; max-width: 100%; min-width: 0. The same pin was added to .dt-page and .dashboard-page as insurance. Contain the parent before you blame the child.
.servers-container is also .main-container, which LAYER 2 gives overflow: hidden for the pane stack β€” leaving a page that doesn't have a pane stack unscrollable. Put the scroll back explicitly: .servers-container { overflow-y: auto; overflow-x: hidden; }. A shared layout rule aimed at one page will find every page that shares its class.
The servers detail modal is hand‑rolled (.detail-overlay / .detail-modal), so LAYER 3's .modal-content promotion never reached it. Promote it explicitly β€” height: 100dvh, no max‑width, no radius.
The flat pages had no shell at all β€” mobile.js returned early when there was no .main-container. Restructure the entry point: shell first, then branch. See How it works.

Verification

Unlike the tickets rollout, this one was self‑verified before Ed's device test, using the headless harness described in Techniques & Tricks Β§8:

  • A parse check with a negative control. mobile.js and mobile.css loaded in headless Chrome, followed by a deliberately broken script β€” if the harness doesn't report that file, it proves nothing about the real one. Result: the broken control errored, mobile.js didn't, and mobile.css parsed to 229 rules.
  • Real CSS, hand‑written markup. The page's inline <style> was extracted to a file and linked alongside theme.css, inbox.css and mobile.css, so the harness renders against the actual cascade rather than an approximation.
  • An iframe pinned to 360px inside a larger window. ⚠️ On Windows, --window-size=360 is clamped to a minimum of ~500px CSS width and the screenshot is cropped, which looks exactly like right‑edge overflow when the content is wrapping fine. An iframe gives a true 360px viewport.
  • Measure, don't eyeball. Each run reported innerWidth, documentElement.scrollWidth and every element whose right edge passed the viewport. docScrollW === innerWidth is the pass condition β€” that is precisely the thing whose failure triggers the iOS reflow. This is what caught the 776px settings bug, which no screenshot made obvious.
  • A desktop positive control. The same harness at 1100px, confirming the tables are still tables with their headers β€” proof the @media gate holds, not just an assumption.

Still owed: a real device pass with Ed, which remains the final word.


Known rough edges / future polish

  • A device or application with no manufacturer shows the renderer's - placeholder inline (- Β· 22.190.0.4). CSS can't see text content, and changing the placeholder would change desktop.
  • The table view's column and filter popovers are clamped to the viewport but are still desktop‑shaped popovers, not sheets.
  • library.php, labels.php, assign-tags.php and help.php have the shell but unreflowed bodies.
  • The keyboard‑vs‑bottom‑input problem from the tickets rollout applies here too wherever a sheet has a field near the bottom.
  • aria-label sweep across the injected chrome is still owed, module‑wide.

Reference

  • CSS: assets/css/mobile.css β€” LAYER 14 (round 1) and LAYER 15 (round 2).
  • JS: assets/js/mobile.js β€” initAssetsMobile(), plus the shared injectViewsHamburger() / moveTenantIntoWaffle().
  • Opt‑in wiring: asset-management/index.php, table.php, dashboard/index.php, settings/index.php, servers/index.php (currently mobile.css?v=31, mobile.js?v=14).
  • Changelog: #936 (round 1) and #937 (round 2).
  • Parent: Mobile‑Friendly Β· Sibling: Mobile: Tickets Β· Techniques: Mobile: Techniques & Tricks Β· Module: Assets Β· Driver: Asset QR Labels Β· The other half of Assets-on-a-phone: Asset scanning β€” Developer Guide.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally