Mobile Friendly Tickets - edmozley/freeitsm GitHub Wiki

Mobile: Tickets

The tickets inbox is the first module made mobile‑friendly and the reference implementation for the patterns described in Mobile‑Friendly. This page records what's been done, how it works, the challenges that came up, and how each was solved.

🧰 For the code‑level catalogue of every CSS/JS trick used here — the Shadow‑DOM per‑message horizontal scroll, the iOS reflow trap, section sheets, the sticky day‑heading, and how it's all verified headlessly — see Mobile: Techniques & Tricks.

Scope: the whole module. The inbox (/tickets/), the settings page (#1005), and since LAYERS 37-39 every remaining page: rota, dashboard, widget library, users, calendar, triage, mailbox activity, CSAT analytics, the public CSAT survey and all five guides. See The rest of the module below.


What's been done

Everything below is gated to phone‑width (≤768px); desktop is unchanged.

App shell / navigation

  • Waffle module drawer (top‑left) — a full‑height left slide‑in to switch modules, with a ✕ close button. (This lives in the shared waffle component, so it works on every page.)
  • Views hamburger (top‑right) — a ☰ button opens a right slide‑in drawer with the tickets views (Inbox / Dashboard / Users / Calendar / Rota / CSAT / Settings / Help), with a dim backdrop that closes it.

The inbox itself

  • Master‑detail stack. The desktop 3‑pane split (folders / list / conversation) becomes a one‑pane‑at‑a‑time slide stack, defaulting to the ticket list.
    • Tap a ticket → the conversation slides in full‑screen.
    • A ‹ Back control (and the device back button) returns to the list.
    • A ☰ Folders control opens the folder list; picking a folder returns to the list.

The open ticket (reading pane)

  • Ticket reference in the sub‑bar — the ticket number (e.g. LUN‑779‑88063) sits at the top‑right of the sub‑bar, on the same row as ‹ Back and in the same colour/size, so it's out of the subject's way.
  • Subject‑only heading — the heading drops the "Ticket <ref> –" prefix and shows just the subject, truncated to one line.
  • Attachment badge — the full‑width yellow "…has N attachments" bar is squished into a compact 📎 badge with a count, right‑aligned on the subject row (taps through to the attachment list).
  • Email‑details chevron — the ▾ that reveals From/To/Date sits right after the subject (moved away from the attachment badge, which was easy to mis‑tap). The whole subject line toggles the details; the badge is a separate tap target.
  • Collapsible header — the From/To/Date/Cc block starts collapsed behind the subject + ▾ chevron (Gmail‑app style); tap to expand.
  • Single‑row bottom action bar — the action buttons are icon‑only and pinned to a single row at the bottom; the first five show and the rest live behind a "⋯" overflow popover (with their word labels).
  • Section panels — crowded sections are relocated out of the ticket into their own full‑screen sheets, each opened by an icon button:
    • 🔗 Links — problem / change linking
    • ⚙ Properties — status / priority / owner …
    • ⏱ Time entries
    • 🖥 Affected objects (CMDB)
  • Company (tenant) switcher — on multi‑company installs, moved out of the cramped top bar into the module/waffle drawer, restyled for the light panel.

The action panels (all full‑screen on a phone)

Every reading‑pane action opens as a proper full‑screen panel with a real working area:

  • Add note — full‑screen sheet; the note box flexes to fill the whole panel.
  • Reply / forward — full‑screen sheet; the To/Cc row stacks and the rich‑text editor grows to fill between the fields and the Send bar.
  • Schedule — full‑screen sheet; native date/time inputs tamed so they don't spill off the right.
  • Ask AI — the 420px side panel becomes full‑width 100dvh, message list scrolling, input pinned at the bottom.
  • Audit history — on a phone this is not the desktop modal at all. The desktop 5‑column table (Date / Analyst / Field / Old / New) is wider than the screen, and on iOS a too‑wide element makes Safari reflow the page to a desktop width — which switches the mobile rules off and drops the modal back to a small centred box (the same "spills wide → reflows to desktop" trap as the reply modal). So mobile routes audit through the same full‑screen .mobile-sheet slide‑over used for Links / Properties / Time / Objects, filled with a day‑grouped feed of one card per change: field + time on top, old → new beneath (old struck through), who did it under that. The date is a sticky heading said once per day; a first‑time value shows just the new value (not "‑ → Open"); long values wrap rather than clip. Audit history isn't in the reading pane to relocate, so the sheet fetches it on open (the endpoint inbox.js already uses). Desktop keeps its table, untouched.

How it works

mobile.js loads after inbox.js and wraps the globals inbox.js already exposes — it never edits the large inbox.js (the one exception being a single TinyMCE content_style string; see below).

Pane state on <body>

The active pane is a single attribute, so CSS ancestor selectors drive the whole layout:

body[data-mobile-pane="reading"] .email-list-container { transform: translateX(-100%); }
body[data-mobile-pane="reading"] .reading-pane         { transform: translateX(0); }

Wrapping the existing handlers

List rows already call selectEmail(id); folders already call selectFolder(type,id). mobile.js wraps them:

var _selectEmail = window.selectEmail;
window.selectEmail = function () {
    var r = _selectEmail.apply(this, arguments);
    if (mq.matches && currentPane() !== 'reading') pushPane('reading');   // slide to conversation
    if (r && typeof r.then === 'function') r.then(afterTicketRender);     // relocate sections + refinements
    return r;
};

pushPane calls history.pushState, and a popstate listener restores the pane — so the device back button pops conversation → list. It also reads the page's own state directly (currentEmail, ticketAttachments are top‑level lets, shared across classic scripts) to build the reference badge, subject‑only heading and attachment badge, and wraps renderAttachmentInfoBar to refresh the badge when attachments load async.

Section sheets are config‑driven

Each crowded section is one entry in a list; a sheet is built for each, and after every ticket render the nodes are moved in and a toolbar button added:

var SECTIONS = [
    { cls:'links', title:'Links',            icon:'🔗', label:'Links',      sel:'.problem-strip',             all:true  },
    { cls:'props', title:'Properties',       icon:'⚙',  label:'Properties', sel:'#ticketPropertiesContainer', all:false },
    { cls:'time',  title:'Time entries',     icon:'⏱',  label:'Time',       sel:'#timeEntriesContainer',      all:false },
    { cls:'cmdb',  title:'Affected objects', icon:'🖥', label:'Objects',    sel:'#cmdbObjectsContainer',      all:false }
];

Adding another panel later is one line in this array.

mobile.css layers

The stylesheet is one @media (max-width: 768px) block, organised into numbered layers:

Layer Purpose
1 App shell — top bar, views hamburger + right drawer, .user-menu clamp
2 Inbox master‑detail pane stack + the Back/Folders sub‑bar
3 Modals → full‑screen sheets; per‑modal fill (note box, reply/forward TinyMCE); 16px anti‑zoom fields; native date/time fix
4 Reading‑pane density (tighter side padding, smaller subject)
5 Action toolbar → icon‑only bottom bar (single row + "⋯" overflow)
6 Collapsible ticket header
7 Section sheets (Links / Properties / Time / Objects)
8 Opened‑ticket refinements — ref in sub‑bar, subject‑only heading, 📎 badge, chevron reorder, tenant switcher in drawer, body.ticket-popout backstop
9 Ask‑AI chat panel → full‑width 100dvh
10 Audit history → its own full‑screen .mobile-sheet with a day‑grouped card feed (not the desktop modal)

(The waffle drawer itself lives in includes/waffle-menu.php, not mobile.css, so it works on every page.)


Challenges & solutions

Challenge Solution
A 3‑pane split is a non‑starter on a phone. Master‑detail stack: absolutely‑positioned panes slid with transform, one visible at a time, state in body[data-mobile-pane].
The reading pane's height: calc(100vh - 48px) assumed a fixed 48px header — but the mobile header wraps and is taller, pushing the conversation off‑screen. On mobile the body becomes a flex column; the header takes its natural height and .main-container uses flex: 1; min-height: 0. 100dvh instead of 100vh.
The device back button would leave the app instead of closing the conversation. Each in‑pane navigation history.pushStates; a popstate listener restores the pane.
Back didn't return to the list (a real bug, #762). Root cause: a saved desktop tickets_popout pref made inbox.js add body.ticket-popout on every open — including on the phone — and its .email-list-container { display:none } hid the list. Fixed by neutralising pop‑out on mobile at the source: wrap syncPopoutToTicketState to strip the class when mq.matches, with a CSS backstop; Back also forces the list pane directly. (General lesson: desktop localStorage modes leak onto mobile — watch for other body classes doing this.)
Too much crowds the open ticket. Relocate each section into its own full‑screen sheet; collapse the header; single‑row icon bottom bar + overflow.
The reading pane re‑renders on every open, so anything moved gets recreated inline. afterTicketRender runs after each render (hooked onto the promise selectEmail returns) and re‑moves the nodes + (idempotently) rebuilds the toolbar. Moving a node keeps its id, so async loaders still find it.
Properties is normally an absolute collapsible dropdown; in a sheet it would be invisible. In the sheet its panel is forced position: static; max-height: none; opacity: 1.
The add‑note / reply sheets had a small field with dead space below (built as centred desktop boxes). Make the modal a flex column and let the field grow to fill (scoped by #id); the TinyMCE editor is found via .form-group:has(#emailBody) and set to flex: 1; height: auto !important to beat its inline height.
iOS zoomed and the sheet "went to desktop mode" when you tapped a field. iOS auto‑zooms fields under 16px, which spills the sheet wide → Safari reflows to desktop width → the max-width:768px rules stop matching. Fix: 16px on every modal field; for the reply editor (iframe, CSS can't reach), set the TinyMCE content_style to 16px on @media (pointer: coarse) only, so desktop mouse users stay 14px.
The schedule panel's native date/time inputs spilled off the right. iOS gives them an intrinsic width that ignores width:100%. Fix: -webkit-appearance: none + max-width: 100% + reset ::-webkit-date-and-time-value margin; the native picker still opens on tap.
Injected mobile chrome must not appear on desktop, but mobile.css can't set a desktop display:none. Sheets created with inline display:none; sub‑bar and hamburger toggled by syncBar() on load and on matchMedia change.
No way to self‑verify visually — the inbox needs a login and there's no headless browser. Build to spec, prove desktop safety statically, iterate in a tight loop on a real device (full‑clear Safari each time — it caches the HTML page, so ?v= bumps don't fully bust it). Most refinements came directly from that testing.

The settings page (#1005)

tickets/settings/ is the largest settings screen in the product — 18 tabs, 17 tables, 5,776 lines — and it came along on LAYER 15e plus three additions it forced.

⚠️ It is the first page that had to opt OUT of a LAYER 2 rule. Its own stylesheet documents, in as many words, that <body> must not be flex: "a flex <body> turned extension‑injected nodes (e.g. LastPass) into flex items and wrecked the layout". So it builds the same structure one level down in a .settings-shell wrapper. LAYER 2 makes <body> a 100dvh flex column on every opted‑in page — opting this one in blind would have re‑created a bug somebody had already hit, diagnosed and left a comment about.

<body data-mobile-page="settings" data-mobile-shell="own">
body[data-mobile-shell="own"] { display: block; height: auto; }
.settings-shell { height: 100dvh; }   /* 100vh is wrong once mobile chrome shows */

🔑 Read a page's own CSS comments before opting it in. The warning was right there, and nothing in the harness would have caught it — an extension node is not something a headless run has.

Three things this page forced into the shared settings layer, all of which now benefit every module:

Found here Now the shared rule
17 tables, some inside a .settings-group, one inside an inline‑styled scroll wrapper — a third structural variant after .settings-section-body and .tab-content > Every table on a settings page scrolls. Enumerating containers was losing to the codebase; all 17 carry a <thead>, so the selector is simply "a table on a settings page"
A datetime-local at the browser default of 13.3px, and two fields with an inline font-size: 14px The anti‑zoom rule now reads input:not([type=checkbox]):not([type=radio])… — excluding what must keep its native size rather than listing what's covered — and carries !important, because an inline style beats any ordinary rule
Nine inline display:grid; grid-template-columns:1fr 1fr forms Inline multi‑column grids stack on a settings page

⚠️ The grid one had a second half that looked exactly like the first half failing. Stacking the template to 1fr changed nothing visible, because 35 children carry an inline grid-column: span 2 — and a child spanning two columns of a one‑column grid makes the browser create an implicit second column. The tell was that the two tracks came out unequal (measured 93px + 111px); 1fr 1fr would have been even. Both the template and the spans have to be neutralised.

🔑 The bug the eye caught that the measurements didn't. The SLA tab's two‑column radio form passed every overflow check — it didn't overflow, it just squeezed "When a ticket's priority changes mid‑flight" into a 150px column five lines deep. That is the Calendar settings lesson for the third time, so this round stopped relying on eyes for it: the harness now asserts "nothing inside the container is still laid out in more than one column at phone width" — a question measurement can answer.

Verification walked all 18 tabs, activating each and measuring it. The page renders one tab at a time, so measuring it as loaded verifies 1/18 of it — the single‑tab pass reported clean, and the walk is what found the fields and the grids.


The rest of the module (LAYERS 37-39)

Fourteen pages, done in one round: rota, dashboard, widget library, users, calendar, triage, mailbox activity, CSAT analytics, the public CSAT survey and the five guides.

⭐ All fourteen were 1404px wide for the same reason

Before being opted in, every page measured docScrollWidth: 1404 on a 360px screen — the same number, to the pixel, on pages that share almost nothing else. The cause is the desktop navigation bar they all include, and opting them in fixed it on all of them at once: .header-nav becomes the right-side drawer from LAYER 1, and injectViewsHamburger() gives it an opener wherever .header and .header-nav exist, because it keys on those rather than on the inbox.

🔑 One identical number across unrelated pages is a shared cause. It is worth measuring the whole set before fixing any of it — five of the fourteen then needed nothing else at all.

Needed nothing beyond the opt-in Why
dashboard/index.php, dashboard/library.php .dashboard-page and .widget-grid are the same class names Assets and Software already carry, so the whole shell arrived free — the fourth round to benefit from that pair
csat/index.php already fitted
the public survey reached at /csat; see the 404 note below
all five guides .help-container takes the scroller role from LAYER 16h and the contents strip already scrolled sideways

The rota: three readings, because the reading is a preference (LAYER 37)

Six columns — a name column and five days — which §11 says a sideways scroller cannot help past about four. It was already scrolling sideways by accident, with both of §18's signatures on .rota-grid-wrapper: overflow-y declared alone computes overflow-x to auto, and a horizontal scrollbar was eating 15px of height.

Ed's call, and it settled a question that had no single right answer: which reading you want is a personal preference, so all three stay — Day (one day, everyone on it), Analyst (one person, days stacked) and Week (the grid, scrolling sideways deliberately now) — flipped by a sticky footer in the shape Knowledge's .article-actions already uses. Zero new translation keys: common.view_day, tickets.group_analyst and common.view_week already existed, and each stacked cell labels itself from its own data-date.

Three things that only driving it found, all written up in Techniques §31:

  • The chooser was made of the things it filters, so each view showed one choice and no way to reach another. The probe reported all three views working.
  • 1fr resolves against .rota-grid's min-width: 700px, so day view computed 104px 596px and nothing actually narrowed.
  • The tagging had to be re-applied on a MutationObserver, because rota.js rebuilds the grid on every week change — otherwise the view reverts on the first tap of next-week with nothing announcing it.

⚠️ Dates in the analyst view use fmtNaiveDate(), never fmtDate(). A rota day is a naive calendar date; the UTC-instant formatter converts into the viewer's zone and moves the date across midnight for anyone west of UTC — wrong for some users and correct for everybody testing in London.

Users: the pane that had no width (LAYER 39a)

Tapping a user, or a group, appeared to do nothing and was working the whole time — 1,748 characters loaded into a box 0px wide at x=360. Full write-up in Techniques §32. It is a pane stack now, one at a time with a Back control, and groups come along because selectGroup() draws into the same pane.

🔴 The Back button was wiped by the first tap, because that same redraw replaces the pane's contents — §21's observer reasoning, needed again one layer after it was written down for the rota.

Mailbox activity, triage, calendar (LAYER 38, and 39b for the icon buttons)

  • Activity was §30 in its plainest form: a flex row with a fixed 280px sidebar left the content pane 80px wide, and the clipping body reported the page perfectly contained. Its search box was 194px starting at x=310.
  • Triage measured 843px with four cells that could not be scrolled to. Both it and activity became card feeds — each has a prose column, and you cannot scroll a sentence sideways. Labels are harvested from the tables' own headers through the §21 registry.
  • The calendar's two toggles ran to x=432. ⚠️ .view-toggle / .view-btn are four modules' class names and mobile.css already said so: the existing fixes are scoped to .calendar-main and .tasks-main, and the tickets calendar has neither — precisely how the Tasks calendar missed out before it. Scoped to this page's own marker rather than widening either rule.
  • Its ticket panel uses icon buttons on a phone (Ed's request). The icons cannot come from CSS: an icon-only button with no text has no accessible name, so the already-translated label is harvested into aria-label and the markup is put back exactly when the viewport leaves mobile.

CSAT: a comment column 57 pixels wide (LAYER 39d)

grid-template-columns: 60px 56.83px 160px — a fixed 60 for the rating and a fixed 160 for the analyst and date, leaving the subject and the customer's comment fifty-seven pixels. One word per line.

⭐ It had measured clean twice, for two different reasons, both in Techniques §33: the page had no data inside its default 30-day window, and then the crushed-cell check demanded a cell over 60px tall.

⬜ Not reproduced

Ed reports the calendar day view using only half the screen width. It measures correctly on every route in — the Day button from month, from week, and a month-cell tap — at 60px for the time column and 300px for the events column. Two latent faults of that shape are guarded (.day-events-column would not shrink below its content, and .day-body declares only overflow-y), but the report is open.


The right-click menu on a phone (2.10.0)

Ed's request: a long press on a ticket should give the desktop's right-click menu, as a full-screen panel, and a row that leads somewhere (Set priority) should slide the first level off and its options in. User-facing description: Tickets § Right-Click Context Menu.

🎨 File What it does
🖥️ assets/js/inbox.js (v144) wireTicketLongPress(), ticketMenuIsSheet(), openTicketMenuSheet(), openTicketMenuLevel(); a branch in openTicketContextMenu(); closeTicketContextMenu() resets the sheet; the scroll-closes-menu listener ignores scrolls inside it; row dragstart refused below 768px
📱 assets/css/mobile.css (v154, all 181 pages bumped) The .ctx-sheet block at the end of the file, inside @media (max-width: 768px); reduced-motion variant
🌐 lang/en/common.php Reuses common.close and common.back — no new strings
❓ tickets/help.php, lang/en/tickets.php tickets.help.working.rightclick_phone

What it does not touch: tickets/index.php. There is no second menu — the sheet is #ticketContextMenu, with the same items and handlers, restyled.

One menu, two shapes

openTicketContextMenu() builds the menu exactly as before (selection rule, conditional items, every submenu populated), then, instead of positioning it at the cursor:

if (ticketMenuIsSheet()) {          // matchMedia('(max-width: 768px)')
    openTicketMenuSheet(menu);
    return;
}

openTicketMenuSheet() adds .ctx-sheet and, the first time only, appends a close button and an empty .ctx-sheet-level2 panel, plus one delegated click listener that is inert unless .ctx-sheet is present — so the desktop's hover flyouts are untouched.

The second level is a copy of the flyout

Tapping a .ticket-context-menu-parent copies that parent's .ticket-context-submenu innerHTML into the level-two panel under a Back header:

level2.innerHTML = '<div class="ctx-sheet-level2-head">…Back…<span class="ctx-sheet-level2-title">' + escapeHtml(label) + '</span></div>'
                 + '<div class="ctx-sheet-level2-list">' + sub.innerHTML + '</div>';
void level2.offsetWidth;            // lay it out off-screen first, or the slide is skipped
menu.classList.add('ctx-drilled');

A copy is safe because every submenu item uses an inline onclick (setPriorityFromContext(3), snoozeFromContext('tomorrow') …) built by the populateContext*Submenu() functions. ⚠️ If a future submenu item is wired with addEventListener instead, its copy will look right and do nothing on a phone. Keep submenu items inline, or change this to move the node rather than copy it.

The slide is CSS: level one's children get transform: translateX(-100%), level two (fixed, full-screen, its own scroll) goes from translateX(100%) to 0. The void offsetWidth read replaced a first attempt with requestAnimationFrame, which can fire late enough to add .ctx-drilled after the menu has closed.

The long press

Touch only — a mouse has a right button. Document-level listeners, so the rows can be re-rendered freely:

  • touchstart on a #emailList .email-item starts a 500ms timer (passive).
  • touchmove beyond 10px cancels it: the finger is scrolling the list.
  • When it fires: a short vibration where supported, then openTicketContextMenu() with a synthetic event whose target is the row (so the Outlook selection rule works unchanged).
  • touchend after it fired calls preventDefault() (non-passive listener), which stops the browser turning the lifted finger into a click that would also open the ticket.

Three things had to change around it:

  1. Android fires its own contextmenu on a long press — so the inline oncontextmenu on the row opens the menu too. openTicketContextMenu() ignores a second open within 900ms of the sheet opening, so the second call cannot reset a level already tapped into.
  2. iOS turns a long press on a draggable element into a drag. Ticket rows are draggable (onto folders). On a phone the folder pane is a closed drawer, so the drag could never land anywhere — dragstart is refused below 768px. The rows also get -webkit-touch-callout: none and user-select: none so iOS shows no Copy / Look Up bubble.
  3. The menu closed on any scroll (window scroll listener, capture phase) — fine for a popover, fatal for a full-screen sheet you scroll. Scrolls whose target is inside the menu are now ignored.

Verified

Headless Chrome at 390px against a throwaway database, with real TouchEvents: a 700ms hold opened the sheet full-screen (390×844) with the right ticket in the header; the touchend was prevented; scrolling inside did not close it; Set priority drilled to its six options; Back returned; choosing High closed the sheet and the ticket's priority was High in the database; ✕ closed it; a finger moved 40px during the hold did not open it. At 1300px a real contextmenu still opened the 220px popover at the cursor, with no sheet parts. Screenshots confirmed both levels' end states. Not verifiable headless: the slide animation itself (headless with a virtual time budget does not advance CSS transitions) — check it on a device.


Phone polish for 3.1.2 (#2165-#2173)

Nine items from Ed using the inbox, Users and CSAT on a phone. Every change is inside the @media (max-width: 768px) block, in mobile.js behind mq.matches, or in inbox.js behind ticketMenuIsSheet() - the desktop is unchanged. Versions after the round: mobile.css?v=176, mobile.js?v=74, inbox.js?v=149. Two new English strings, tickets.tasks.modal_title and tickets.tasks.none_yet; everything else reuses translated keys.

# Where What
2165 LAYER 5, mobile.css The channel reply composer's Attach / Suggest / Summarise / Send row
2166 mobile.js, section sheets Links (N) in the footer's "…" panel
2167 LAYER 3, mobile.css Link to problem / change / ticket / task lists full height
2168 inbox.js, tickets/index.php, api/tickets/search_linkable_tasks.php Long-press Link to task opens a phone-only modal
2169 LAYER 39d, mobile.css CSAT headline cards two across
2170 inbox.js, LAYER 3 mobile.css The attachments popup as cards
2171 LAYER 39a, mobile.css Users: the detail pane scrolls, the user's tickets as cards, long names cut short
2172 LAYER 39a2, mobile.js Users: the four actions in a sticky footer
2173 mobile.js, the inbox pane stack A ticket opened by id shows the reading pane

The reply composer's four buttons (#2165)

Under a Telegram, WhatsApp, Slack or web chat reply box, .channel-composer-actions is one unwrapped flex row on the desktop and ran off the right of a phone. It becomes a two-column grid, words kept, each button at least 40px tall with min-width: 0. When only three buttons are shown, the odd one out takes the whole last row (:last-child:nth-child(odd) { grid-column: 1 / -1 }), so the grid never ends in a hole.

Links (N) in the "…" panel (#2166)

The section-sheet buttons now wrap their word in a .msb-label span so it can be rewritten, and syncLinksCount() counts every a.pm-ticket-badge inside the Links sheet's .links-strip. The word only shows in the "…" panel (the bar is icon-only), but the count is set either way.

⚠️ A one-off count at relocate time would be wrong. Equipment, CMDB, domain and task pills arrive by their own fetches after the strip is drawn, and unlinking redraws a group in place. A MutationObserver on the Links sheet's body recounts whenever its pills change - the same observer reasoning as §21.

Full-height link lists (#2167)

The link sheets were already full screen, but inbox.css caps .lp-list at 50vh, which left the bottom half of the phone blank. On the four link modals the .modal-body becomes a flex column with overflow: hidden, and the list takes flex: 1; min-height: 0; max-height: none and scrolls itself - so the search box stays put above it.

🔴 Link to task opened out of sight (#2168)

From the long-press menu (The right-click menu on a phone), Link to task loaded the ticket and opened the strip picker. On a phone that strip lives in the Links sheet, which mobile.js has moved out of the reading pane and keeps hidden - so the picker opened where nobody could see it, and the menu item looked like it did nothing.

openContextLinkTask() now branches on ticketMenuIsSheet() (with a TRAP: comment saying why) and opens #linkTaskModal, a new phone-only modal in tickets/index.php built in Link to change's shape: a search box, a Create "…" row once something is typed, then the list. It uses the same endpoint and the same link / create calls as the strip picker, including the move it from the other ticket? confirm, because a task belongs to one ticket. Rows are picked by index rather than with the title inlined into an onclick, so a title with a quote cannot break it. The desktop keeps the strip picker.

Two changes behind it:

  • search_linkable_tasks.php answers an empty search with up to 20 tasks (a typed search still returns 10), in its existing order: open tasks first, then most recently updated, leaving out subtasks and tasks already on this ticket. It used to return nothing for an empty q; the desktop picker never sends one, so the desktop is unaffected.
  • postTaskLink() only reloads the task strip if the ticket is the open one. From the phone modal it may not be.

CSAT headline cards (#2169)

.kpi-row is repeat(3, 1fr), and 1fr has a min-content floor, so three 20px-padded cards with 32px figures pushed Response rate off the right of the screen. On a phone it is repeat(2, minmax(0, 1fr)), the third card spans the row underneath, the padding drops to 14px and the figure to 26px.

The attachments popup as cards (#2170)

A six-column table cannot fit a phone. showAttachmentList() now calls a new attachmentCardsHtml() when ticketMenuIsSheet() is true: one full-width card per attachment, with the preview on top when the browser can show one (image, audio or video), then the filename and From / Date/Time / Size as label-value lines using the table's own translated column labels. Tapping the details downloads, as a table row does, and the send to tracker button comes along when a tracker is linked. The heading puts Attachments and the ticket number on two lines, reusing the reply modal's already-translated tickets.reply_modal.attachments. On the desktop the .att-card* classes never exist; the table and the 240px preview tiles are untouched.

Users, round two (#2171, #2172)

After LAYER 39a gave the detail pane a width, three things were still wrong in it:

  • It could not be scrolled. The desktop clips the pane (overflow: hidden) and scrolls only the tickets list at the bottom. On a phone the header and the single-column info grid fill the screen, so that list was squeezed to nothing and nothing was left that scrolled. The whole .user-detail-container scrolls now, and the tickets section takes its natural height.
  • The user's tickets were a five-column grid (130px 1fr 150px 80px 130px), leaving the subject a couple of letters wide and Priority and Created off the right. Each row is a small card on named grid areas - number and status, the subject in full, then priority and date - and the header row goes, since there are no columns left for it to label.
  • The four buttons ran off the right. #2171 wrapped them under the name (!important, because selectUser() writes those rows with inline styles); #2172 then moved them into a sticky footer at Ed's request.

In the list, a very long name or username is cut to one line with an ellipsis; the full value is on the detail pane.

The footer (LAYER 39a2) is the Domains 41i shape: fixed at the bottom, z-index: 1200 so dialogs at 2000 cover it, env(safe-area-inset-bottom) for the iPhone home indicator, Delete set apart in the danger colour, and 72px of padding under the last ticket card so the bar does not hide it. As on Domains:

  • The real buttons move, never copies, so their handlers come with them. They are matched by what they do (a[href*="person"], openUserModal, manager-access.php, deleteUser), not by their translated words, and each one's words go into aria-label and title, because an icon has no name.
  • selectUser() redraws #userDetail wholesale, so a MutationObserver refills the footer on every redraw, as the Back button is re-added. The previous user's buttons simply go with the old markup.
  • A group has none of the four, so the footer hides; it is also hidden while the list pane is showing. Add to address book stays in the header.
  • Above 768px the buttons go back where they were.

🔴 A ticket opened by id loaded behind the list (#2173)

The pane stack works by wrapping the existing handlers, and only selectEmail() was wrapped. The other way a ticket opens is loadTicketById() - the ?ticket_id= deep link (a user's ticket on the Users page, a notification, Calendar's Open in inbox) and the linked-ticket pills - which renders through displayEmail() and never touches selectEmail(). So on a phone the ticket loaded into the hidden reading pane and you were left looking at the inbox list.

mobile.js now wraps loadTicketById() the same way: push the reading pane if it is not already showing, then run afterTicketRender() once its promise resolves.

🔑 A master-detail stack has to wrap every route into the detail, not just the obvious one. A route that bypasses the wrapper still works - it just works where nobody can see it.


Known rough edges / future polish

  • Bottom‑anchored inputs vs the keyboard — the note/reply/Ask‑AI compose inputs can sit behind the iOS keyboard. A visualViewport fix was tried and made it worse, so it was reverted. This is the top remaining rough edge and needs a safer approach.
  • Accessibility — icon‑only action buttons rely on the emoji being recognisable; an aria-label sweep across the injected chrome is still owed.
  • The collapsed header shows the subject only — not a Gmail‑style one‑line sender+date summary (that would need editing the shared ticket renderer).
  • ✅ The other tickets pages are done — LAYERS 37-39, see The rest of the module. Every one of them was 1404px wide before being opted in, for the same shared reason: the desktop navigation bar.
  • General density/spacing polish is ongoing.

Reference

  • assets/css/mobile.css (LAYERS 1–10, 15e for the settings page, and 37–39 for the rest of the module), assets/js/mobile.js.
  • Waffle drawer: includes/waffle-menu.php (@media block in its own <style>).
  • Opt‑in wiring lives in tickets/index.php and tickets/settings/index.php (<head> link + script tag, versioned — currently mobile.css?v=39, mobile.js?v=21, inbox.js?v=53). ⚠️ mobile.css and mobile.js are now shared across five modules — bump the ?v= on every one of the 19 pages that link them, not just this one, or a page will run the stale file.
  • Changelog: settings page #1005; mobile inbox shipped across entries #756–#768 (plus #855 audit‑history feed) (master‑detail + shell → modal sheets → reading‑pane polish → views/bottom‑bar/section‑panels → reference/subject/badge/overflow refinements → Back fix → tenant switcher + chevron → full‑height action panels for note/reply/forward/schedule/Ask‑AI); phone polish for 3.1.2 #2165-#2173 (LAYER 39a2 in mobile.js is the Users footer).
  • Parent: Mobile‑Friendly · Siblings: Mobile: Assets, Mobile: Calendar, Mobile: Knowledge, Mobile: Service Status.
⚠️ **GitHub.com Fallback** ⚠️