Skip to content

Mobile Friendly Watchtower

Ed Mozley edited this page Aug 25, 2026 · 3 revisions

Mobile: Watchtower

Watchtower is the sixth module made mobile‑friendly, after Tickets, Assets, Calendar, Knowledge and Service Status. Same hard rule: one @media (max-width: 768px) block, desktop byte‑identical.

🧰 Code‑level catalogue of the techniques: Mobile: Techniques & Tricks.

Why Watchtower? It answers one question β€” "is anything on fire?" β€” and that is the question you ask on the way to your desk, not once you are sitting at it. It is the module with the strongest case for being read on a phone and it was the least usable there.

Shipped as #1179, commit 486313ac. LAYER 21.


Scope

Page State
watchtower/ β€” the attention dashboard Done β€” top bar stacked, card grid single column, metrics gridded (LAYER 21)
watchtower/settings/ Done β€” inherits the shared settings layers, no bespoke rules needed
watchtower/help.php Done β€” inherits 16h from the Calendar round

Like Service Status before it, this shipped with zero lines of mobile.js β€” mobile.js stayed at v22. The dashboard has no pane stack and nothing worth wrapping; the shared shell is the whole of its behaviour.


What it looked like before

Measured at a 360px viewport, before any change:

viewport = 360   docScrollW = 864   CONTAINED = false

864px on a 360px screen. That is not "a bit wide" β€” it is the iOS reflow‑to‑desktop trap: Safari gives up on the viewport and lays the page out as a desktop, at which point every max-width: 768px rule stops matching and the phone gets the full desktop UI, shrunk.

The cause was not in Watchtower's own CSS. It was the shared header row β€” waffle, title, nav, and .header-right with the bell and avatar β€” which cannot wrap. .header-right sat at right = 864. That is LAYER 1's job, and the module simply had never opted in.

πŸ”‘ Opting in did most of the work. Three <link>/<script> tags took it straight to docScrollW === 360, zero uncontained elements, a single‑column grid and a working scroller. Everything below is the difference between contained and usable.


LAYER 21 β€” what needed doing anyway

The page must declare its own scroller

.wt-container {
    height: auto;
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    padding: 12px;
}

.wt-container is height: calc(100vh - 48px) β€” which quietly assumes the header is exactly 48px tall. LAYER 2 makes body a flex column, and a flex item that states its own height ignores the space actually available to it. Every opted‑in page has to do this: Tickets with .main-container, Assets with .container / .dt-page, Help with .help-container.

The top bar had to stack

Side by side with the timestamp and the Refresh button, the title had roughly 150px and "Attention Overview" wrapped onto two lines. Stacked, it gets the full width and the timestamp sits with the button it belongs to:

.wt-top-bar { flex-direction: column; align-items: stretch; gap: 8px; }
.wt-title   { font-size: 17px; }
.wt-refresh-btn { padding: 9px 14px; font-size: 13px; }   /* ~44px tap target */

⭐ Metrics: a grid, not a wrapping flex row

This is the one worth stealing.

The card bodies render a row of figures β€” Open, In progress, On hold, Awaiting response, Resolved. Desktop uses display: flex; flex-wrap: wrap, which packs as many as fit per line. On a phone the Tickets card put four on the first row and two on the second, and the two did not line up under the four, because "AWAITING RESPONSE" is nearly twice the width of "OPEN".

.wt-metrics {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 10px 4px;
}
.wt-metric { min-width: 0; }
.wt-metric-label {
    text-align: center;
    overflow-wrap: anywhere;   /* long labels wrap INSIDE their column */
    letter-spacing: 0;
}

A grid gives every card the same three columns whether it has three figures or six, and overflow-wrap: anywhere makes the long label wrap inside its column instead of stretching it. min-width: 0 is required β€” grid items default to min-width: auto and would otherwise refuse to shrink below their content.

πŸ”‘ flex-wrap optimises for packing; a grid optimises for alignment. On a dashboard you are comparing numbers, so alignment is the thing that matters.

One column below 768, not 700

The module's own stylesheet already stacked the grid at max-width: 700px. That left 701–768px on two columns β€” a ~370px card, narrower than the phone layout this whole file is written for. LAYER 21 restates it at the shared breakpoint.


⚠️ The gotcha that cost the most time

mobile.css must be linked after the page's inline <style> block.

It was linked before it. Every LAYER 21 rule then lost at equal specificity β€” .wt-container in the page beats .wt-container in mobile.css purely on document order β€” and the measurements did not move at all: same 24px padding, same 304px card, before and after.

There is no error, no warning and no partial effect. It looks exactly like the CSS not being loaded, or the file not having been saved.

Every other opted‑in module already does it correctly (Service Status: </style> at line 33, mobile.css at line 362), which is worth checking against when a new layer appears to do nothing.

πŸ”‘ When a stylesheet change has precisely zero measured effect, suspect load order before suspecting the rules. A rule that is wrong usually moves something.


Round 2 (#1180) β€” after a run through it on a real device

Two faults, both found by Ed on a phone, neither caught by the measurements.

The dot on a wrapped alert floated between the lines

.wt-attention-item was align-items: center, which centres the 6px dot against the whole two-line block β€” so on "12 tickets paused over 24h (SLA clock stopped)" it belonged to neither line.

.wt-attention-item { align-items: flex-start; line-height: 1.45; }
.wt-attention-dot  { margin-top: 6px; }   /* (13 Γ— 1.45 βˆ’ 6) / 2 = 6.4 */

The offset is chosen so a single-line row is pixel-identical to the old centring, which means only wrapped rows move.

Fixed in the module's own stylesheet rather than mobile.css: the same row wraps on a 404px desktop card, so this was never a mobile-only bug. It is worth checking whether a "mobile" defect is really a wrapping defect before putting it behind the @media gate.

The ☰ was invisible on the Help page

.mobile-views-btn used color: inherit β€” and it was the only control in the header bar that did. .user-avatar and .nb-btn both declare their own colour and were never affected.

Watchtower's and War Room's help pages set --on-accent on <body>, boilerplate that about 25 pages copy and none of them use, which inbox.css feeds into .header { color }. The result was a near-black glyph on a dark bar: tappable, and completely invisible.

πŸ”‘ Anything mobile.js injects lands in a page whose tokens you do not control. It has to state every property it depends on. See Techniques Β§8.

⚠️ …and the fix was nearly the same bug inverted

The first attempt was color: var(--text, #333), reasoned from measuring the bar with getComputedStyle(el).backgroundColor and walking up for the first non-transparent value. That landed on body, which flips with the theme.

But .header paints with a background-image:

background: linear-gradient(135deg, #1e293b, #0f172a);   /* dark navy in BOTH themes */
/* background-color computes to rgba(0,0,0,0) */

So var(--text) was correct in dark mode and invisible in light. The measurement said it was fine; the screenshot showed it was not. The final fix is a flat #fff, matching its siblings, verified against the gradient's luminance on four pages across both themes β€” including tickets/index.php as an unchanged control.

πŸ”΄ And the honest part: that faint hamburger was visible in a screenshot taken during the original build, and explained away as a headless rendering artefact. If something looks wrong in a screenshot, measure it.


Verification

Harness per Techniques: a PHP page under the app dir that seeds the session cookie and iframes the real authenticated page at a pinned 360px, deleted before committing.

Check Result
docScrollW === innerWidth at 360px βœ… all three pages
Uncontained elements (with the three documented exclusions) βœ… zero, all three pages
A working scroller on each page βœ… .wt-container, .help-container, .container
Looked at, not only measured βœ… screenshots of all three
Desktop control @ 1100px 24px padding, 2‑column grid, body not flex
Desktop control @ 1300px 24px padding, 3‑column grid, body not flex

The desktop controls are the point of the exercise. body { display: block } at 1100px proves the @media block is not reaching desktop at all β€” which is a stronger statement than "it looked the same".


Known rough edges

  • A card with one metric (Morning Checks: 0/6 DONE) now occupies the first of three grid columns rather than sitting alone. It reads correctly and matches the left‑aligned desktop behaviour, but a card‑aware column count would look tidier.
  • The dashboard is a long scroll on a phone β€” nine stacked cards. That is inherent to a dashboard whose job is completeness; a "what needs attention" filter would be a feature, not a mobile fix.
  • Settings checkboxes are the shared settings size rather than a phone tap target. That belongs to the shared settings layers, not here.

Reference

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally