Mobile Friendly Assets - edmozley/freeitsm GitHub Wiki
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).
| 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/users.php — who holds what |
Done (round 3, LAYER 43) — the same list ↔ detail pane stack as Tickets → Users, equipment as a labelled card feed |
asset-management/manager-access.php (and tickets/manager-access.php — one shared page) |
Done (round 3, LAYER 43d) |
asset-management/dashboard/library.php, labels.php, help.php, help-custom-fields.php
|
⬜ Not opted in at all. This row used to say "shell only", which was wrong: none of them links mobile.css, so they have no shell either. Found in round 3. |
asset-management/handover.php |
Not part of this pattern: a printable document, opened in a new tab |
- 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: noneon 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.
- 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.
Assets → Users had never been opted in — no mobile.css, no mobile.js, no marker — so nothing from the two rounds above reached it. Measured at 360×740 before a line was written:
| Found | Answer |
|---|---|
| 🔴 The header nav ran to 1,379px — §3's reflow trigger: on an iPhone the whole page goes back to desktop width | Opting in: the shared ☰ drawer takes the nav |
<body> clips, so the person's detail — stacked by the page's own 900px block under a 320px list — could not be reached
|
A pane stack (below) |
| The equipment table 516px in a 326px panel | A card feed with §21 labels |
| Every field 13–14px; an Add button 24px tall | 16px; tap-sized |
The pane stack is Tickets → Users', made shared. Users is that page with assets in it — its own header comment says so — and LAYER 39a had already solved it. Rather than copy the block, it became a list of pages (STACKS in mobile.js): each names its list, its detail and the globals that open a record. Extract on the second use — this page's own rule from round 1.
selectPerson() inline for ?user_id=N, before mobile.js has loaded, so the wrap never saw it: the stack would sit on the list with the person loaded out of sight. Each entry now names a deepLink parameter, and a page opened with it starts on the detail. Tickets → Users' link happened to work (it waits for the list first), and now does so by design.
🔴 The list panel measured 357px in a 336px slot — and min-width: 0 changed nothing. The page's own grid says align-items: start, which in a grid top-aligns the two panels; turned into a flex column it means do not stretch the width, and the panel sized itself to its content. §16 exactly — and it was the computed alignItems that found it, after the obvious guess had moved nothing.
The equipment becomes cards: the name leads with its type pill at the right, make and model on the next line, and serial, asset tag and issue date carry their headings — two codes that look alike and a date that could mean anything. The table is rebuilt with the whole detail pane, so its FEEDS entry watches the pane (§30's note on tables that do not exist at load). The labels name the app's own font, because they otherwise inherit the code font of the serial and tag cells.
Manager access is one page shared by both modules (includes/manager_access_page.php), so its marker is the page's — data-mobile-page="manager-access" — and both copies were opted in together. Its search results were a table with a 12px column and ten crushed cells; they are now a name at full width with Add / Exclude beneath. <style> inside <body>, after mobile.css, so LAYER 43d wins on specificity, never on order (§24).
🐞 And a bug at every width, found by looking at the cards: the Assigned date read — for everybody. The page defined a top-level fmtDate that replaced tz.js's for the whole page and then called itself. Fixed with two more of the same family — see Dates shown as a dash, or in server time.
Verified at 360×740 — tap a person → their detail and Back; Back → the list; a search changes the list; the Edit dialog a full-screen sheet with all 12 fields at 16px — and on Tickets → Users as a positive control after the refactor, deep link included. Desktop control at 1100px on Users (list and a person), both Manager access pages and Tickets → Users: identical, no Back button and no pane attribute at desktop width.
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 |
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.
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.
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 belowTwo 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()andmoveTenantIntoWaffle()were extracted as shared functions in round 1 when Assets became the second caller — extract on the second use, not the third.
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');
...
};| 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.
</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.
| 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. |
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.jsandmobile.cssloaded 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.jsdidn't, andmobile.cssparsed to 229 rules. -
Real CSS, hand‑written markup. The page's inline
<style>was extracted to a file and linked alongsidetheme.css,inbox.cssandmobile.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=360is 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.scrollWidthand every element whose right edge passed the viewport.docScrollW === innerWidthis 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
@mediagate holds, not just an assumption.
Still owed: a real device pass with Ed, which remains the final word.
- 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.phpandhelp.phphave 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-labelsweep across the injected chrome is still owed, module‑wide.
- CSS:
assets/css/mobile.css— LAYER 14 (round 1) and LAYER 15 (round 2). - JS:
assets/js/mobile.js—initAssetsMobile(), plus the sharedinjectViewsHamburger()/moveTenantIntoWaffle(). - Opt‑in wiring:
asset-management/index.php,table.php,dashboard/index.php,settings/index.php,servers/index.php(currentlymobile.css?v=31,mobile.js?v=14). - Changelog: #936 (round 1) and #937 (round 2).
- Parent: Mobile‑Friendly · Siblings: Mobile: Tickets, Mobile: Calendar, Mobile: Knowledge, Mobile: Service Status · Techniques: Mobile: Techniques & Tricks · Module: Assets · Driver: Asset QR Labels · The other half of Assets-on-a-phone: Asset scanning — Developer Guide.