Mobile Friendly Techniques - edmozley/freeitsm GitHub Wiki

Mobile‑Friendly: Techniques & Tricks

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.


Contents

  1. Wrap, don't edit — extending a page's JS from outside
  2. ⭐ Independent horizontal scroll per message (Shadow DOM)
  3. The iOS "reflow to desktop" trap — the meta‑lesson
  4. Section sheets — crowded panels get their own screen
  5. The sticky day‑heading (and its peek‑through trap)
  6. Master‑detail + the device back button
  7. Reflow & polish patterns
  8. Verifying without a device — headless rendering
  9. ⚠️ Load order — why a whole layer can do nothing
  10. Aligning numbers: grid, not flex-wrap
  11. ⭐ Card feed or horizontal scroller?
  12. ⚠️ The sideways scrollbar that looks vertical
  13. 🔴 A sticky header taller than its scrollport
  14. ⭐ min-width: auto — the flex item that will not shrink
  15. ⚠️ A generic class name is usually TWO modules' class name
  16. ⭐ Rules that only misbehave once ANOTHER layer restacks them
  17. ⚠️ CSS comments do not nest — and a brace count cannot see it
  18. 🔴 A filter that removes false positives will eventually remove a true one
  19. ⭐ The signature of a card feed, read from measurements alone
  20. 🔴 A native date input ignores the width you give it
  21. ⭐ Give a card feed its column headings back — harvested, not hardcoded
  22. 🔴 Which drags actually work on a phone
  23. ⭐ The flex‑basis that has to be a middle value
  24. ⚠️ A shared include can emit CSS after your stylesheet
  25. 🔴 A mobile layer can only REVEAL — hide it at source
  26. 🔴 A hover‑revealed control is not a control on a phone
  27. 🔴 A stacking property is inert on the wrong display type
  28. 🔴🔴 A page that fits the width can still be unreachable
  29. ⚠️ A third-party widget has its own modes
  30. 🔴 A clipping body cannot report an overflow
  31. 🔴 A chooser must not be made of the things it filters
  32. 🔴 A pane with no width — and the fix that moves nothing
  33. ⚠️ Four ways a probe reports a page it never measured

1. Wrap, don't edit

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.js edit in the whole effort was a single TinyMCE content_style string — CSS genuinely can't reach inside the editor's iframe — and even that is gated on @media (pointer: coarse).


2. ⭐ Independent horizontal scroll per message

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-y stays hidden (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).


3. The iOS "reflow to desktop" trap

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: 768px media 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.


4. Section sheets

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.


5. The sticky day‑heading

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.


6. Master‑detail + the device back button

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');
});

7. Reflow & polish patterns

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;
}

8. Verifying without a device

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 equal

Prove 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-pane class 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.


⚠️ Measure the property that actually paints

getComputedStyle(el).backgroundColor is not "the colour behind this element". An element painted with a gradient reports backgroundColor: rgba(0, 0, 0, 0), and a walk up the tree looking for the first non‑transparent background sails straight past it and lands somewhere else entirely.

The app header is exactly this shape:

.header {
    background: linear-gradient(135deg, #1e293b, #0f172a);   /* dark navy, BOTH themes */
    /* background-color computes to transparent */
}

Measuring backgroundColor and walking up landed on body — which does flip with the theme (#f5f5f5 light, rgb(20,23,28) dark). That measurement argued convincingly for a theme‑aware var(--text) on the header's icons, which is correct in dark mode and invisible in light. The fix for an invisible icon was very nearly an invisible icon.

So when checking contrast:

const cs   = getComputedStyle(el);
const grad = cs.backgroundImage;                       // check this FIRST
const stops = (grad.match(/rgba?\([^)]+\)/g) || []);   // a gradient's real colours
const bg    = stops.length ? stops : cs.backgroundColor;

🔑 A measurement is only as good as the property you chose. docScrollW === innerWidth is trustworthy because there is only one way to be wrong. Colour is not — there are at least four ways for something to be painted, and reading one of them tells you nothing about the other three.

🔴 Don't explain away a screenshot

The faint hamburger in this round was visible in a screenshot taken during the original build, and dismissed as a headless rendering artefact. Ed found it on a device afterwards.

Screenshots are noisy — antialiasing, mid‑transition states, colour profiles — so there is always a plausible reason to discount one. That is exactly what makes the habit dangerous.

🔑 If something looks wrong in a screenshot, measure it. Deciding it is probably fine costs nothing at the time and everything later. The measurement takes a minute and either clears it or finds a real bug — and the two are indistinguishable until you do it.


9. Load order

<a name="9-load-order"></a>

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

Nearly every module page carries its own <style> in the <head>. Link mobile.css above it and every rule that targets the same selector at the same specificity silently loses on document order:

<!-- WRONG — the page's own .wt-container wins -->
<link rel="stylesheet" href="../assets/css/mobile.css?v=62">
<style> .wt-container { padding: 24px; height: calc(100vh - 48px); } </style>

<!-- RIGHT — mobile.css is last, so its @media rules win -->
<style> .wt-container { padding: 24px; height: calc(100vh - 48px); } </style>
<link rel="stylesheet" href="../assets/css/mobile.css?v=62">

This happened on the Watchtower round. LAYER 21 was written, the version was bumped, the page was reloaded — and the measurements did not move at all: same 24px padding, same 304px card, identical before and after.

🔑 When a stylesheet change has precisely zero measured effect, suspect load order before suspecting the rules. A rule that is merely wrong usually moves something. A rule that never applies moves nothing, and looks exactly like a file that did not save or a cache that did not bust — both of which you will check first, and both of which will be fine.

Every opted‑in page already does it correctly, so a new module can be checked against one: Service Status closes its <style> at line 33 and links mobile.css at line 362.

⚠️ Ordering the <head> is not always sufficient. A shared component included inside <body> can emit its own <style>, which lands after every stylesheet in the <head> including this one — see §24. There the override has to win on specificity instead.

Avoiding !important is the point of getting the order right. LAYER 3 uses it for .modal-content because that rule has to beat inline styles; nothing else should need it.

⭐ And when the thing in your way is an inline style, there is a third option that is usually better than either. An inline style beats any selector without !important, so the choice looks binary — reach for !important, or give up. It is not: give the markup a class and change nothing else. Adding class="x" alters no rendering at any width, so desktop is untouched by construction, and the mobile layer then has a normal selector to aim at. The Checklists step rows were done this way (three class names, no style changes) rather than adding a fourth !important to the file.


10. Aligning numbers: grid, not flex-wrap

A row of figures — a dashboard card's Open / In progress / On hold / Awaiting response / Resolved — is usually built as display: flex; flex-wrap: wrap. That is right on desktop and wrong on a phone.

flex-wrap packs as many items as fit per line. With six metrics on a 328px card that 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". The result is contained, wrapped, and unreadable as a set of comparable numbers.

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

Three things make it work:

  • repeat(3, 1fr) gives every card the same columns whether it holds three figures or six, so the numbers line up down the page as well as across.
  • min-width: 0 is required. Grid items default to min-width: auto, which refuses to shrink below the content's intrinsic width — the long label would blow the column out exactly as it did under flex.
  • overflow-wrap: anywhere makes the long label wrap within its column instead of widening it.

🔑 flex-wrap optimises for packing; a grid optimises for alignment. On a dashboard the reader is comparing numbers, so alignment is the property that matters. This is a good default for any "row of stats" on a phone.


11. Card feed or horizontal scroller?

A wide table on a phone has two honest answers, and the choice gets re-argued every time because both are defensible. This is the rule the codebase has actually been following, written down.

The decision

Turn it into a card feed when… Keep the table and contain it in a scroller when…
The reader takes in one row at a time The reader compares values down a column
Reading order carries the meaning without the header Values need their header to mean anything — an unlabelled Resolved? / Default? boolean
The columns are known (you wrote them) The columns are user-chosen or open-ended (the assets table view, the servers list)
Three or four self-describing values Seven columns — reading order cannot carry that many

🔴 The follow-up question the table above does not ask

"Does the reader compare values down a column?" is the right question on a desktop. On a phone there is a prior one:

CAN they, on this screen?

The LMS Progress report — analyst, course, group, status, score, deadline, last access — was kept as a scroller on exactly the reasoning in the table: seven columns, a report you read down the Status column across people, the case a card feed destroys. That is sound, and it survived until somebody used it.

At 360px the scroller shows three of eight columns. So "read down Status across people" means dragging sideways to find Status, then scrolling vertically while holding that horizontal position in your head, on a surface where any vertical drag with a hint of sideways in it moves the wrong axis. The comparison the scroller was protecting was not available either way.

🔑 A scroller only preserves a comparison the screen is wide enough to hold. Past about four columns it preserves the shape of one and nothing else. Count what actually fits at 360px before arguing the reader needs the table.

Reversed to a feed, with §21 labels on all five columns that cannot speak for themselves. Ed's word for the old one was that it scrolled left and right; the honest reading is that the original call was right about the desktop and wrong about the phone.

⭐ The tell that settles most cases: does a column hold prose?

If any column contains a sentence, it is a card feed. There is no second consideration.

  • You cannot scroll prose horizontally. You would scroll right to finish the sentence and left again to start the next row. Nobody does this twice.
  • You cannot squeeze prose either. Measured on the Problem Management detail view: an incident subject in a four-column table got an 82px column and rendered 134px tall — seven lines for one sentence.

⚠️ The crushed table is the dangerous one

Of the three tables on that page, only one was reported — the History panel, whose What column visibly ran off the right-hand edge at 345px inside a 328px section.

The other two never spilled. They stayed inside the screen and squeezed instead, and so they passed every check we have: docScrollW === innerWidth, no uncontained elements, nothing overflowing. They were less readable than the one that overflowed.

🔑 Overflow gets reported; crushing does not. A table that spills looks broken, so somebody tells you. A table that squeezes looks like the app is just a bit cramped, so people put up with it. When you find one wide table on a page, measure the others — and measure the cell, not the table: a cell that is 82px wide and 134px tall is the signature.

Building the feed

.pm-table, .pm-table tbody { display: block; width: 100%; }
.pm-table thead            { display: none; }
.pm-table tbody tr         { display: block; border-bottom: 1px solid var(--border-soft, #f0f0f0); }
.pm-table tbody td         { display: block; border-bottom: none; }

Then re-inline the cells that belong together on one line:

/* A quiet "when · who" line, then what actually happened — the same shape the
   module's own notes already use. `display: inline` and NOT a flex row: flex
   blockifies its items, so a flex column cannot put two cells on one line. */
.pm-table tbody tr:has(.pm-when) .pm-when,
.pm-table tbody tr:has(.pm-when) .pm-when + td { display: inline; }
.pm-table tbody tr:has(.pm-when) .pm-when::after { content: " · "; }

Use :has() to tell sibling tables apart. Three tables here share one class, and .pm-when exists only on history rows — so the history-specific rules scope themselves without a single change to the markup.

⚠️ Do not swallow the empty state

.pm-table tbody tr.pm-empty-row td { display: block; text-align: center; }

An empty state is usually one <td colspan="4"> — which makes it the :first-child and the :last-child of its row. Any rule keyed on either position applies to it, and "pin the last cell to the corner" quietly deletes "No incidents linked yet." That is exactly how the Calendar card feed lost its message.


12. ⚠️ The sideways scrollbar that looks vertical

"A scrollbar appears but the contents don't scroll."

Reported on the change detail view, and the vertical scroll had been working the whole time — it was the wrong bar. The pane was scrolling sideways, and grabbing that bar moved almost nothing.

Why a pane you only made vertical scrolls horizontally

.changes-main { overflow-y: auto; }   /* overflow-x is left at `visible` */

Per spec, if one axis is not visible, the other computes to auto. So a pane given overflow-y: auto is silently a horizontal scroller as well, and any child wider than it produces a bar rather than an error.

That is also why every containment check passed. The overflow was absorbed by an ancestor scroller — which the overflow detector deliberately excludes, because that exclusion is what stops it crying wolf on shipped code. The page was contained and the content still sat off the edge of its box.

The tell

const box = Math.round(el.getBoundingClientRect().height);
box - el.clientHeight   // 15px = a horizontal scrollbar is present

clientHeight excludes a horizontal scrollbar; the border box does not. A 15px gap between an element's box height and its clientHeight means one is there. It is the only signal that shows up without looking at the page.

Then measure children against the pane's content edge, not its border edge:

const contentRight = pane.getBoundingClientRect().right
                   - parseFloat(getComputedStyle(pane).paddingRight);

⚠️ A negative margin belongs to its own parent's padding

Three things were too wide, and fixing the third went wrong in a way worth recording. The detail's sticky header full-bleeds with margin: -30px, and the obvious move was to match it to the pane's new 12px padding.

Wrong box. Its parent is .change-detail-content, whose 30px padding is what the -30px cancels. Setting -12px against a still-30px padding un-bled it and made the overflow worse — which is how the mistake announced itself.

🔑 Find the element whose padding the negative margin is actually cancelling, and move both numbers together. Walk the parent chain and print each ancestor's padding rather than assuming which box is being escaped — the container you changed and the container it is inside are often not the same one.


13. 🔴 A sticky header taller than its scrollport

"All the information is there but the page will not scroll."

Reported twice on the same screen, and nothing about it looks like a scrolling problem — because there isn't one. The page scrolls perfectly. You just cannot see it happen.

What happens

.change-detail-sticky-header { position: sticky; top: -20px; }

That bar holds the title, the actions and an 11-item meta grid. Its height depends entirely on how wide the screen is:

bar height pane height
1200px 388px 592px pins, content scrolls beneath it, behaves
360px 818px 590px taller than the thing it is pinned inside

A position: sticky element taller than its scrollport can never scroll away. It stays pinned, covering the entire visible area, and the content moving underneath is never seen. What looks like the top of a stuck page is the stuck header, filling the screen.

Any sticky element whose height grows when the layout stacks — a summary card, a filter bar, a toolbar with wrapping controls — can do this. It is invisible on desktop by construction, because that is the width where somebody designed it.

⚠️ Why the usual checks all pass

Every measurement said the page scrolled, because it did:

pane.scrollHeight   // 5332
pane.clientHeight   // 590
pane.scrollTop = 500;
pane.scrollTop      // 500  ← works perfectly

scrollTop moves an element whose content is entirely hidden behind a pinned block, exactly as it moves one that is visible. Asserting on it proves the container can scroll, not that anything a person can see moves.

⭐ The check that actually finds it

Ask what is under the finger, scroll, and ask again:

const before = d.elementFromPoint(cx, cy);
pane.scrollTop = 500;
await settle();
const after  = d.elementFromPoint(cx, cy);
// before !== after  ← THIS is what "the page scrolled" means

Here it went detail-meta-label → cab-review-section. Before the fix it returned the same element both times, from inside the sticky header.

elementFromPoint at the middle of the viewport is worth reaching for whenever a page "does not respond": it answers what would a tap or a swipe actually hit, which is a different question from what is in the DOM — and it is the question the person reporting the bug is really asking.

The fix

Make it static below the breakpoint. A pinned summary earns its place on a wide pane; on a phone it is 818px of chrome you cannot get past.

.change-detail-sticky-header { position: static; }

14. ⭐ min-width: auto — the flex item that will not shrink

The change editor's six rich‑text fields share one widget behind a tab strip. On a phone it measured 578px inside a 264px parent.

Nothing was overflowing in the way the checks look for:

.cm-rich-text-widget   578px      ← a flex ITEM
its flex parent        264px
.editor-form           292 / 292  ← contained, because IT scrolls

A flex item defaults to min-width: auto, which means it will not shrink below its own min‑content width. Here that was the six tabs laid end to end. The widget therefore ignored its 264px parent, TinyMCE sized its editor to the widget, and an ancestor scroller absorbed the difference — so docScrollW === innerWidth held while the form ran off the side.

.cm-rich-text-widget { min-width: 0; }     /* now it may shrink */
.rich-text-tabs      { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; }
.rich-text-tab       { flex: 0 0 auto; white-space: nowrap; }

Two rules, and the tabs swipe instead of setting a floor.

🔑 min-width: 0 is to flex items what flex-shrink: 1 is to flex rows — and it is the same family of fault as §11's crushed table and LAYER 21's metrics grid. Whenever a child measures wider than its parent and the page is still contained, look for a shrink floor before looking for the overflow.

How to spot it: compare an element's width with its parent's width, not with the viewport. A viewport check cannot see this at all.

The full‑screen panel over a tabbed widget

The Knowledge editor (LAYER 17i) puts one field full screen. This widget holds six behind a strip, so the strip goes full screen with it:

body.cm-editor-full .cm-rich-text-widget {
    position: fixed; inset: 0; z-index: 2000;
    display: flex; flex-direction: column;
    padding-bottom: env(safe-area-inset-bottom);
}
body.cm-editor-full .rich-text-tabs        { flex-shrink: 0; }
body.cm-editor-full .rich-text-panel.active{ flex: 1 1 auto; display: flex; flex-direction: column; min-height: 0; }
body.cm-editor-full .rich-text-panel .tox-tinymce {
    flex: 1 1 auto; min-height: 0;
    height: auto !important;      /* beat TinyMCE's INLINE pixel height */
}

🔑 Full screen on Description that could not reach Rollback would only move the problem. If the widget is tabbed, take the tabs with you.

Three things this pattern always needs:

  • height: auto !important. tinymce.init({ height: 300 }) writes an inline pixel height. A flex: 1 that does not beat it gives you a full‑screen panel with a 300px typing area — which looks like the feature working.
  • A way out that is not the toolbar. This init has no fullscreen plugin, so the panel carries its own Close bar and answers the device back button via history.pushState / popstate.
  • Re‑measure after the class flip. TinyMCE laid its iframe out while the container was small; fire mceAutoResize and a resize on the next tick.

⚠️ Anything injected must live where the page will move it

The open button started as a sibling of the widget. The module's form designer re‑parents the widget with host.appendChild(richTextWidget) to follow whichever section anchors it — and sets display: none on it when nothing does.

A sibling button would have been stranded in the section the widget left, or left offering full screen on a hidden widget. Both the bar and the button go inside the widget, so they travel with it and inherit its visibility.

The test for this is to call the relocating function yourself. refreshFormLayout() then widget.contains(button) — a page that has only been loaded, never re‑laid‑out, will not show you the fault.

🔴 And driving the wrong entry point proves nothing

The first run measured a raw 44px <textarea> and reported "no editor". showView('editor') shows the pane; editCurrentChange() is what calls initEditors(). Reaching a view by the shortest route that renders it can skip the very initialisation you came to measure.

w.showView('editor')        → .tox-tinymce count = 0    ← proves nothing
w.editCurrentChange()       → 6 of 6 initialised        ← the real path

Same family as §13: the harness answered a question next to the one being asked.


15. ⚠️ A generic class name is usually TWO modules' class name

.editor-form, .editor-header, .editor-footer, .editor-scroll — Knowledge and Change Management both named their editor the same things. LAYER 17's

.editor-form { padding: 14px; }     /* written for Knowledge */

had therefore been styling the change editor since the day it was written. Harmless in that direction, purely by luck; the same mistake the other way would have silently restyled Knowledge.

The fix is to scope on a marker only one module sets — here body.cm-editor-open, toggled by change-management.js:

body.cm-editor-open .editor-form { margin: 8px; padding: 12px; }

That also wins on specificity (0,2,0 vs 0,1,0) rather than on load order, which is the sturdier of the two — load order is exactly what went wrong in the Watchtower round.

🔑 Before writing a rule for a bare .editor-* / .detail-* / .list-* / .card-* selector, grep it across the other modules. The same instinct as §11's .pm-modal-content: there, a module's own class made it invisible to a shared rule; here, a shared class made one module visible to another module's rule. Both faults come from the name, and neither shows up in a measurement of the page you are working on.

The check is a second iframe. Load the other module in the same headless run and assert its values are unchanged — a control, not an inspection.


16. ⭐ Rules that only misbehave once ANOTHER layer restacks them

Four faults from one round of Change Management, all the same shape: a declaration that is correct where it was written, and wrong only because a mobile layer changed something around it.

align-items swaps axis with flex-direction

.risk-scoring-row       { align-items: flex-end; }        /* the module: BOTTOM-align */
.editor-form .form-row  { flex-direction: column; }       /* the mobile layer */

align-items acts on the cross axis. Flip the row to a column and flex-end stops meaning bottom and starts meaning right — three controls shrank to their content and lined up against the right edge, and neither rule's author could have seen it.

🔑 Before restacking someone else's row, grep it for align-items, justify-content, align-self and margin-*: auto. All four swap axis with the direction. A restack is never just a restack.

width does nothing on an inline box

.btn      { padding: 10px 20px; }   /* no display */
.btn-full { width: 100%; }

<a class="btn btn-full"> is inline, and width does not apply to inline boxes — so btn-full had never done anything on that element. It looked deliberate (a content-width button) right up until someone wanted it aligned. display: inline-block is what makes both the width and a min-height tap target take effect.

A control whose insides are shadow DOM

<input type="datetime-local"> renders its date, time and picker in shadow DOM. Those parts overflow inside the control without changing its scrollWidth — the field measured 144 / 144, perfectly contained, while reading as clipped at 146px wide.

And they cannot be rearranged: ::-webkit-datetime-edit-* is WebKit/Blink-only and iOS Safari draws the control as a native spinner that ignores it. A rule that "works" in headless Chrome does nothing on the phone the bug came from.

🔑 For a native control the only reliable lever is how much room you give it. When asked to restyle the inside of one, say so and widen it instead.

A style that was never there at all

.search-btn { width: 100%; }   /* the ONLY rule for it in that module */

The phone drew the browser's default button — grey, square, 13.3px, 19px tall — beside a properly styled one. It had presumably always looked like that, and reads as "a button we haven't got to yet" rather than as a bug, which is why it survived a whole rollout.

Measure the chrome as well as the content. A control at 19px is failing a 44px tap target, and that is a number, not an opinion.


17. ⚠️ CSS comments do not nest — and a brace count cannot see it

/* Explaining a rule...
       .risk-scoring-row { align-items: flex-end; }   /* = BOTTOM-align */
   ...this line is now CSS, not prose... */
.the-rules-after-it { /* silently eaten while the parser recovers */ }

The inner */ closes the outer comment. Everything after it is parsed as declarations, and the parser discards until it can resynchronise — taking the following rules with it.

The symptom is precisely zero measured effect, which is the Watchtower signal for load order — so it sends you looking in the wrong place. Braces still balance, because the eaten text contained none.

Worth having in the checks next to the brace count:

awk '{ n=gsub(/\/\*/,"",$0); m=gsub(/\*\//,"",$0);
       if (c && n) print "NESTED /* at line " NR;
       if (!c && m>n) print "STRAY */ at line " NR;
       c += n-m; if (c<0) c=0; if (c>1) c=1 }' assets/css/mobile.css

🔑 A heavily commented stylesheet — which this one is, deliberately — makes this likelier, not less likely. Quoting a rule inside a comment is exactly when it happens.

🔴 And the twin the check above CANNOT see: a comment that closes EARLY

Extending a comment block in self-service.css left the old */ in the middle of it, so the second half of the prose became CSS — and ate the rule that followed, which happened to be the display: flex the whole fix depended on. The symptom was a property computing as block while the stylesheet plainly said flex.

Both the brace count and the nesting check above passed, because braces stayed balanced and every /* still had its */. Nothing was nested; a comment had simply ended in the wrong place.

The signature is one line, and it is cheap:

Outside a comment, a line whose first non-space character is * is prose that has escaped its comment.

⚠️ Test the state at the START of the line, not after it. The first version tested inComment after processing and flagged 32 innocent lines — every comment whose closing */ sits on a *-prefixed continuation line, which is that file's entire house style. A check that cries wolf on the house style gets switched off, and then it is not a check at all.

{
  wasInComment = inc                      # <- the state that matters
  # …scan the line for /* and */, updating inc…
  if (!wasInComment) {
    t = $0; sub(/^[ \t]+/, "", t)
    if (t ~ /^\*/ && t !~ /^\*\//) print "STRAY PROSE outside a comment, line " NR
  }
}

⭐ Proved against a file with a deliberately early */ before being trusted — §18's rule applied to the checker rather than to the code.


18. 🔴 A filter that removes false positives will eventually remove a true one

Change Management Round 1 added a filter to the sweep: ignore anything with a scrolling ancestor, because a table inside .dt-wrap is a working scroller, not a fault. It was the right fix and it stopped a real false alarm.

In the Software round it hid a real bug. .library-toolbar-right ran to 527px and the sweep reported zero spills on that page — because .dashboard-page was, technically, scrolling.

The difference is one the numbers cannot express:

.dt-wrap { overflow-x: auto } scrolls by design — a wide table is meant to be swiped
.dashboard-page { overflow-y: auto } scrolls sideways by accident — §12: declaring only overflow-y computes overflow-x to auto too

Both look identical to getComputedStyle. Only reading the stylesheet — or looking at the page — tells you which one you have.

🔑 "An ancestor scrolls" means either "working as intended" or "the bug is hidden", and the probe cannot tell you which. When a filter suppresses a finding, that is the moment to look at the page, not the moment to move on.

A screenshot caught it. That is now the third round in a row where the thing that mattered most was found by looking rather than measuring — after the change cards and the sticky header.

The practical form

When a sweep passes, check the two accidental‑scroller signatures before believing it:

// 1. a pane that scrolls sideways when only overflow-y was declared
el.scrollWidth > el.clientWidth && getComputedStyle(el).overflowY === 'auto'

// 2. the 15px tell — a horizontal scrollbar eating height
el.getBoundingClientRect().height - el.clientHeight === 15

19. ⭐ The signature of a card feed, read from measurements alone

Worth knowing because misreading it costs a duplicate rule. From the Software inventory:

Application Name  324 x 20     ← display: block  (the heading cell)
Publisher          13 x 17     ← display: inline (the width of the text "—")
Installed On       34 x 17     ← display: inline (a count badge)

I read "Publisher crushed to 13px" and wrote a second card feed to fix a table that already had one.

⭐ A real table row shares ONE height across every cell. Here that would have been 43px, which is exactly what the desktop control reports. Cells measuring 20, 17 and 17px tall, with widths that track their content rather than a column grid, are a feed — not a crush.

Crushed looks different and is unmistakable once you know both: a crushed cell is narrow and tall — 82 × 134, 106 × 158, 132 × 110 — because the text is wrapping many times inside a column it cannot escape. A feed's cells are short.

width height
table cell, healthy column‑sized same as every sibling
table cell, crushed narrow tall — 5+ lines
feed cell, inline content‑sized one line

20. 🔴 A native date input ignores the width you give it

<input type="date"> is drawn by the platform, not by your CSS. iOS Safari gives it an intrinsic width that ignores width: 100%, so it sizes itself to the date it renders and disregards the box you put it in.

That produces two symptoms, and most of the trouble comes from only ever recognising the first:

Symptom What it looks like
It spills off the right a slight horizontal drag on a panel that otherwise looks fine
It is wider than its neighbours in a row of fields, the date field out-measures the <select> beside it — even though one shared CSS rule sets both

The second is the more useful tell, because it is visible at rest. When a date field and a select share a single width: 100% rule and still render at different widths, nothing is wrong with the CSS — the date field is simply not reading it.

⚠️ A desktop browser cannot show you this

This is the trap that makes it recur. The rendering is platform-specific:

  • Chrome on Windows draws 12/05/2026
  • iOS Safari spells the month out — 12 May 2026

So the field is materially wider on the device than in any browser you can test in, and a desktop measurement understates it. On the Tasks detail panel it understated it enough to show the row fitting at 13px, while it was spilling on a real iPhone. Sections §8 and §14 both apply, but neither will save you here: the headless run is measuring a different control.

🔑 Never clear a native date field on a desktop measurement. Either test on a device, or force the field to a width wider than iOS would need (~210px) and prove the row survives it.

The fix

.detail-panel .detail-input[type="date"] {
    -webkit-appearance: none;       /* makes iOS honour the box model */
    appearance: none;
    width: 100%;
    box-sizing: border-box;
}
.detail-panel .detail-input[type="date"]::-webkit-date-and-time-value {
    margin: 0;                      /* a default margin that also pushes width */
    text-align: left;
}

The native picker still opens on tap. max-width alone does not tame it.

🔴 This has been needed five times

mobile.css now carries five copies of that reset, because no selector reaches across modules:

Where Layer
`.modal-content input[type="date" "time"]`
.asset-info-grid input[type="date"] Asset detail pane
`body[data-mobile-page="settings"] input[type="date" "time"
.editor-form input[type="date"] Knowledge editor
.detail-panel .detail-input[type="date"] Tasks detail panel (#1206)

🔑 Treat a bare input[type="date"] in a phone layout as needing this EVERY time, not occasionally. It is not an edge case that some layouts trip — it is the default behaviour of the control. Add the reset when you add the field, the same way you add 16px for §3's anti-zoom.

Related: Mobile-Friendly §2 records the original #767 finding, and the Tickets and Assets rounds each hit it. Promoted here in the Tasks round because it kept being written up per module and never as a technique — which is exactly why it was missed a fifth time.


21. ⭐ Give a card feed its column headings back — harvested, not hardcoded

<a name="21--give-a-card-feed-its-column-headings-back--harvested-not-hardcoded"></a>

Every card feed in this rollout has dropped its column labels, and always for the same reason: the only pure‑CSS way to put one back is

td:nth-child(5)::before { content: "Submissions"; }   /* ❌ hardcoded English */

and FreeITSM ships in 24 languages. So §11 settled on "reading order carries the meaning instead" — which is right when the values are self‑describing, and is also half the reason a table gets kept as a scroller: "values need the header" is one of the four rows in §11's decision table.

The labels are already on the page and already translated — in the <thead> the feed hides. Copy them onto the cells and print them from the attribute:

// mobile.js — text nodes only, so a .sort-arrow span doesn't come with it
cell.setAttribute('data-mobile-label', text);
td[data-mobile-label]::before { content: attr(data-mobile-label) "\00a0"; }

Zero invented strings, zero new locale keys, and it works in every language the page already works in.

When to reach for it

Only where a value is meaningless without its heading: a bare count, an unlabelled boolean, a bare currency figure. A card whose every line is prefixed reads like a spreadsheet, so the implementation takes an explicit list of column indexes rather than labelling everything.

🔑 This does not overturn §11 — a seven‑column comparison table is still a scroller. What it removes is the case where a feed was the right shape and the only objection was that two of its columns could not speak for themselves.

Five things that will bite

  • Gate it on mq.matches. Do not write the attribute on a desktop render at all; then the desktop control can assert [data-mobile-label] finds nothing, which is a much stronger check than asserting the ::before is empty.
  • Take the header's text nodes only. textContent drags sort arrows, badges and counts into the label.
  • Skip any row whose cell count differs from the header's. The empty state is one <td colspan="N">, and a label stamped on it lands on the wrong thing — §11's warning in a new place.
  • Observe the table, don't wrap the renderer. The list is rebuilt on load, on search and on every sort. A MutationObserver is one line and cannot get out of step with call sites mobile.js does not own; four wraps can.
  • 🔴 Never label an EMPTY cell. A label on nothing renders as the word Score followed by silence — a heading for a fact that is not there, and on a report where half the rows are not started that is half the cards. Every feed before the LMS Progress one happened to have no empty labelled columns, which is why it took five modules to surface. Two guards, because they fail differently: the harvester declines to stamp a cell whose textContent is blank, and the CSS hides td:empty outright so the row keeps no gap where the cell was. ⚠️ The CSS half needs its specificity checked against the feed's own rules — a bare td:empty loses to td:last-child:not(:first-child), so the empty action cell survived while every other empty cell vanished. Which looked exactly like the rule working.

Introduced in Forms (#1289), where two of eight columns were bare counts.


22. 🔴 Which drags actually work on a phone

<a name="22--which-drags-actually-work-on-a-phone"></a>

"Drag surfaces are out of scope on a phone" has been written into this rollout since the start, and as a blanket statement it is wrong. It cost a scope question in the Forms round that Ed answered by pointing at shipped code doing the thing I had just said was impossible.

Kind of drag On a phone
HTML5 drag‑and‑drop — draggable="true" plus dragstart / dragover / drop Works. iOS Safari has driven this API from a long press since iOS 11. It needs touch-action: none on the grip so the browser scrolls the page instead of nothing happening. Shipped and working in Morning Checks settings and the Forms builder.
A hand‑rolled pointer drag — mousedown + mousemove + mouseup Does not work. There are no touch handlers, so nothing fires. A resize divider or a mapper canvas is this, which is why they really are out of scope.

🔑 Grep for draggable="true" before deciding. If it is there, the drag is very probably already working on the device and the only question is whether the grip is big enough to press. If the module listens for mousedown instead, it is a rewrite, not a CSS layer — and out of scope stands.

⚠️ You cannot settle this in a headless browser. Neither kind of drag can be exercised there, so the evidence is either the device or the fact that an identical mechanism is already shipped and working somewhere else in the product.

⭐ And this section answers a narrower question than it looks

It tells you whether the drag fires. It does not tell you whether the feature the drag belongs to is usable, and the Network Mapper round is the case that separates them.

That canvas has both kinds at once. Repositioning a node is hand-rolled mousemove and cannot work. Dropping a new node from the palette is draggable="true" and, by everything above, very probably does — it even places the node at the drop point.

Shipping the half that works would have been wrong:

⭐ You could place a node and then never move it again. On a 360px canvas a mis-drop is permanent, and the half that is missing is the recovery, not the action.

🔑 After establishing that a drag fires, ask what the user does next if they get it wrong. A feature whose undo is the part that needs a mouse is not a working feature — it is a trap with a working first step.

⭐ And the follow-up is the useful bit. Ed asked for the drag regardless, which made the question "what would have to be true for this to be safe?" — and the answer was already written above: supply the recovery. So the round shipped place, move AND delete together, the last two being what turn the first from a trap into a feature. The objection was never "placing is impossible"; it was "placing without a way back".

🔑 When a scope has been declined on a "you could not undo it" argument and somebody asks for it anyway, the answer is rarely "ship it as it was". It is to build the undo alongside it. The original reasoning tells you exactly what the missing piece is.

⭐⭐ The question this section does not ask: what is the drag FOR?

Three canvas modules have now been through here and given the same grep result — hand‑rolled mousedown/mousemove, zero touch handlers — and landed in three different places:

what the drag does what shipped
Process Mapper positions steps, and every per‑step action is behind contextmenu read‑only
Network Mapper positions nodes — and placing/deleting had no other route read‑only, then three new gestures
Workflow rearranges a layout the module generated (const COL_X = 220; // x at which auto-layout stacks nodes) fully usable, no touch layer at all

The Workflow editor adds nodes with toolbar buttons, configures them in a panel, and deletes them with a real button. So the whole of authoring already worked by tap, and the drag was decoration.

🔑 The grep tells you whether the drag fires. It does not tell you what breaks if it never does. Before scoping a canvas, find out how a thing gets created, edited and deleted — if none of those is the drag, there may be nothing to build. Three modules, one grep result, three correct answers.

⚠️ A hand-rolled touch drag CAN be verified headlessly — a native one cannot

The line above ("you cannot settle this in a headless browser") is about HTML5 drag-and-drop. The Network Mapper round could not use that anyway — its palette is a sheet covering the canvas, so it has to get out of the way mid-gesture and a native drag will not survive its source element sliding off screen. The hand-rolled replacement listens for touchstart/touchmove/touchend on document, and those can be driven with synthetic TouchEvents, so all three gestures were exercised end to end before shipping. Still not a device pass: iOS's own gesture arbitration is the part no harness reproduces.


23. ⭐ The flex‑basis that has to be a middle value

<a name="23--the-flex-basis-that-has-to-be-a-middle-value"></a>

Every card feed in this rollout opens the same way: an identifier on the left of the first line and a status badge pushed to the right. One flex row, two items, and the rule for the identifier is one line — which was written three times before it was right, because both obvious values are wrong and they fail in opposite directions.

/* the card's first line */
tbody tr { display: flex; flex-wrap: wrap; }

flex: 0 1 auto — the identifier claims the whole line

auto makes the basis the item's content width, and flex-wrap decides what fits from the basis, before flex-shrink ever runs. So a long value takes the line to itself and its own badge wraps beneath it.

Measured on a documents feed: a 273px filename on a 328px line, with its status badge pushed to the next row at y=520 while the filename sat at y=500. Every other page in the same feed put them on one line.

flex: 1 1 0 — everything else joins the line

A zero basis asks for nothing, so the identifier fits beside the badge — and beside every other short cell in the row too. On a feed that also carried three bare counts, the header line became identifier + badge + docs + reqs and squeezed the name to 65px wide by 60px tall: §11's crushed signature, introduced while fixing the other page.

flex: 1 1 60% — the value that holds both

Wide enough that the identifier plus its badge fill the line and the next cell has to wrap; small enough to shrink when the badge is wide. It still grows into the remainder, which is what pushes the badge right — so the margin-left: auto that usually does that job is not needed either.

🔑 The tell, and it is general: one page in six laid out differently under rules that were identical. The difference was the data — a single long filename — not the CSS. When a shared rule misbehaves on exactly one page, measure that page's content before re‑reading the rule.

⚠️ Pair it with min-width: 0 on the cells, which is load‑bearing for the reason LAYER 27 records: a flex item's automatic minimum size is its content width, so one nowrap cell pins the whole card wider than the screen while the document stays perfectly contained.

🔴 flex-grow is inert when the row is already full — and it looks like a rule that never applied

The Network Mapper editor bar, after stacking, still measured its title at 4px with flex: 1 1 auto and min-width: 0 both set on it and visibly applied:

.nm-back-btn      w=101   flex 0/0/auto   "← All diagrams"
.nm-editor-title  w=4     flex 1/1/auto   "My network"
.nm-version-pill  w=76
.nm-palette-btn   w=131   flex 0/0/auto   "☰ CMDB classes"

101 + 76 + 131 + three 8px gaps = 332 of a 336px line.

🔑 flex-grow distributes POSITIVE free space. With none to distribute it does nothing at all, and min-width: 0 — which you added to let the item shrink — is then the only declaration with an effect. It shrinks. The two rules you reach for to make an item bigger combine, in a full row, to make it as small as it can go.

The tell is that the item has a grow factor and is still the smallest thing in the row. The fix is not a bigger grow factor; it is to let the row wrap and give the item a middle basis (above), or to take something off the line. Same family as §27: a declaration that is inert in its context is indistinguishable from one that never matched.


24. ⚠️ A shared include can emit CSS after your stylesheet

<a name="24--a-shared-include-can-emit-css-after-your-stylesheet"></a>

§9 says to link mobile.css after the page's own <style> block, and that is right as far as it goes. It is a rule about the page, and a page is not the only thing that emits CSS.

contracts/includes/header.php — a shared component included at the top of <body> — carries its own <style>. Everything in it therefore lands after every stylesheet in <head>, mobile.css included, no matter how carefully the <head> is ordered:

<head>
  <style> /* the page's own rules */ </style>
  <link rel="stylesheet" href="../assets/css/mobile.css">   <!-- correctly last… -->
</head>
<body>
  <?php include 'includes/header.php'; ?>                   <!-- …but not last -->
  <!--  ↳ emits <style> .contracts-sidebar-hover … </style> -->

The rule it emitted was (0,3,0) and collapsed a sidebar to a 16px hover rail — which on a phone is unopenable, since there is no hover. Ordering could not beat it.

Win on specificity instead. The class sits on <html>, so prefixing the element name takes the override to (0,3,1):

html.contracts-sidebar-hover .contracts-layout .contracts-sidebar { /* … */ }

🔑 Before assuming mobile.css is last, grep the page's includes for <style>. A component that is on every page of a module puts its rules after yours on every page of that module, and the symptom — a mobile rule with no measurable effect — is the one §9 teaches you to blame on load order, which is exactly what it is, just not in the file you are looking at.

⚠️ The same applies to a shared component's rules winning on specificity from a stylesheet, which has bitten three times now and always from inbox.css: inbox.css:2653 at (0,3,1) beat LAYER 3's anti‑zoom rule, inbox.css:2549 at (0,3,0) beat 16i's modal inset, and inbox.css:3039's .form-grid .form-group.full-width at (0,3,0) beat a (0,2,0) grid override. Grep the shared stylesheet for any class you are overriding — a rule that loses on specificity is indistinguishable from one that was never written, and from a cache that did not bust.

25. 🔴 A mobile layer can only REVEAL — hide it at source

<a name="25--a-mobile-layer-can-only-reveal--hide-it-at-source"></a>

mobile.css contains nothing but one @media (max-width: 768px) block. That is what makes the rollout safe, and it has a consequence that is easy to state and easy to forget:

A file that only exists below 768px cannot set a default above it.

The wiki already records this for nodes mobile.js injects — "injected chrome must be hidden off-mobile", solved with an inline style="display:none" or a syncBar() toggle. The same rule applies to markup the page itself ships, and that case is easier to get wrong because the element is not injected, it is simply there.

How it goes wrong

A bottom action bar needs an icon for each button, so the icons go into the page's markup — and it feels natural to style them next to the rule for the button they sit in:

/* view.php's own <style> — ❌ this is a DESKTOP change */
.contract-card-header .btn { display: inline-flex; gap: 7px; }
.cv-act-icon { font-size: 15px; }

Nothing here is wrong on a phone. It is wrong on the 1400px screen the module is mostly used on, where five buttons quietly grew emoji. Ed found it within minutes of the round shipping:

"we now have emoji icons on the desktop version buttons... fine for mobile not for desktop. we need to be really careful that mobile friendly work does not alter desktop otherwise it doubles my work checking."

That last clause is the real cost, and it is worth more than the pixels: the whole value of the @media gate is that a desktop screen never needs re-checking after a mobile change. One leak and that guarantee is gone for the entire round, because there is no way to know from the outside which change leaked.

The shape that is always safe

/* the page's own stylesheet — the DEFAULT, i.e. desktop */
.cv-act-icon { display: none; }
/* mobile.css — the REVEAL */
@media (max-width: 768px) {
    [data-mobile-module="contracts"] .cv-act-icon { display: inline; }
}

Desktop keeps the markup and renders nothing, which is provable rather than a matter of inspection.

⚠️ It applies to attributes too, not just styles

The same round added a title to each button so the icon-only bar would still have an accessible name once display: none had taken the label out of the tree. Correct on a phone — and on a desktop it put a hover tooltip on five buttons that never had one. Small, but "small" is how a rule stops being a rule.

Attributes cannot be set from CSS, so this one goes in mobile.js behind mq.matches, and is removed again when the viewport leaves mobile:

function apply() { if (!mq.matches) return; /* … set title … */ }
function clear() { if (mq.matches) return;  /* … remove title … */ }

Harvest the text from the element's own hidden label rather than inventing a string, exactly as §21 does for column headings — then it is right in all 24 locales for free.

The audit worth running before you ship a round

git diff the module and read every line that is not inside the @media block. There should be nothing but:

  • rules that hide something the mobile layer reveals,
  • markup that renders identically because of them,
  • and changes that are genuinely a bug fix at every width — which are fine, but should be named as such rather than arriving as a side effect of mobile work.

Two of this round's survived that test and were flagged to Ed explicitly: a switch track painted with a surface token (invisible in both themes, at every width) and a full-screen button he had asked for. Everything else went back behind the gate.

26. 🔴 A hover‑revealed control is not a control on a phone

<a name="26--a-hover-revealed-control-is-not-a-control-on-a-phone"></a>

A desktop idiom that fails silently on touch: keep a row's actions out of the way until the pointer is over it.

.req-row-actions                { opacity: 0; }
.req-row:hover .req-row-actions { opacity: 1; }

A touch screen never hovers. So on a phone those buttons exist, take up their share of the row, and are permanently invisible — tappable by accident, never on purpose. In the case this was found in, three hidden buttons were also squeezing the requirement text they sat beside down to 104px.

Why it does not get reported

An invisible control reads as an absent feature, not as a bug. Nobody says "the Edit button is broken", because there is no Edit button to be broken — so it survives a device pass, a screenshot review and a bug report, and only turns up when somebody measures the row for an unrelated reason.

It is also invisible to every containment check: nothing overflows, nothing is crushed in a way the document notices, and docScrollW === innerWidth throughout.

The sweep

Two instances turned up in one module a day apart — the row actions above, and search results that separated themselves only by a hover tint, so on touch the list had no edges at all. Both are the same mistake wearing different clothes.

# rules that HIDE something until hover — these are the dangerous ones
grep -n ":hover" assets/css/*.css **/*.php | grep -E "opacity|visibility|display"

A :hover that changes only a colour is fine — it is a nicety a phone simply never sees. A :hover that changes opacity, visibility or display is a control, a border or a label that does not exist on a phone, and needs a mobile answer:

  • actions → make them visible and give them a row of their own, thumb‑sized;
  • separation (a hover tint standing in for a border) → give the element a real border on mobile;
  • a tooltip‑ish reveal → the information has to live somewhere permanent, or go.

📌 Owed: this sweep has not been run across the modules already shipped. Every one of them was built with the same desktop stylesheets, so the same idiom is very likely sitting in several of them.

27. 🔴 A stacking property is inert on the wrong display type — and a class is often both

<a name="27--a-stacking-property-is-inert-on-the-wrong-display-type"></a>

Two ways to stack a two-up row, and they are not interchangeable:

grid-template-columns: minmax(0, 1fr);   /* stacks a GRID  */
flex-direction: column;                  /* stacks a FLEX row */

Set the wrong one and nothing happens, silently. It computes, it appears in devtools, and the layout does not move — which is indistinguishable from a rule that never matched, from a cache that did not bust, and from a file that did not save.

It has now caused three separate faults

  1. LAYER 3's stacking rule sets grid-template-columns, and .form-row in this app is a flex row (inbox.css:2124) — so the Calendar's Start date / Start time stayed side by side. Fixed in LAYER 16 with flex-direction: column, and the comment there says so in as many words.
  2. LAYER 28h repeated it ~4000 lines later, against the same class, with the same property. It sat there for two rounds doing nothing, and measured as applied: display=flex cols=minmax(0px, 1fr).
  3. Swapping one for the other then broke the page the first one had been working on.

⭐ Because a class is often both

contracts/edit.php            .form-row { display: grid; 1fr 1fr }   ← the page's own CSS
rfp-builder/extracted.php     (declares no display)  → inbox.css's FLEX row

Same class name, same module, two different display types — so neither property alone is right for the module.

🔑 Set both. That is not belt-and-braces: each property is inert on the display type it does not belong to, which is the very thing that made the original bug silent and is exactly what makes declaring both safe. One rule that stacks the row whichever it turns out to be.

[data-mobile-module="…"] .form-row {
    grid-template-columns: minmax(0, 1fr);   /* if it is a grid */
    flex-direction: column;                  /* if it is a flex row */
    gap: 0;
}

⚠️ And check the display type before writing either. getComputedStyle(el).display on the real page takes a second and settles it; reading the page's own stylesheet does not, because the display may be coming from a shared file the page never mentions.


28. 🔴🔴 A page that fits the width can still be unreachable

The rollout's whole verification story is "is anything wider than the screen?" — docScrollW against innerWidth, plus the crushed-cell signature for the things that squeeze instead of spilling. Both are about the horizontal axis. Neither has ever asked the other question:

Can you reach the bottom of it?

The LMS console could not be scrolled at all on a phone — four tabs, every one of them stopping dead at the fold — and it had passed four rounds of measurement, because it looked completely right. Every card rendered, in the right place, at the right width, with nothing overflowing.

Why the mobile layer itself causes it

LAYER 2 makes <body> a flex column so a wrapped header can take its natural height and the pane below fills the rest:

body { display: flex; flex-direction: column; height: 100dvh; }

That is load-bearing and correct. But it also means every direct child of <body> is now a flex item, and a flex item's default is flex: 0 1 auto — it shrinks to fit. A module whose top-level container is a plain padded div gets squeezed to the viewport, its content spills out of it with overflow: visible, and there is then nothing left that can scroll:

  • not the container — no overflow was ever set on it;
  • not the document — it is already exactly 100dvh.
/* what every module that opts in actually needs */
[data-mobile-module="x"] .x-container {
    flex: 1 1 auto;
    min-height: 0;          /* or the content's own height becomes the floor */
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
}

min-height: 0 is not optional: without it a flex item will not shrink below its content, so the container grows and you are back to a document taller than the viewport with an overflow rule that never fires.

🔑 Opting a module in does not give it the scroller role — you have to hand it over explicitly. The three LMS shells were given it deliberately (they are display: flex themselves and it was obvious); the console was opted in and assumed to inherit it. It does not.

The check, and it is one line

// on the REAL page, at 360×740, per tab / per view
const s = [...d.querySelectorAll('body, body *')].find(e => {
  const o = getComputedStyle(e).overflowY;
  return (o === 'auto' || o === 'scroll') && e.scrollHeight > e.clientHeight + 2;
});
s.scrollTop = 999999;                     // and assert it actually moved

Two assertions, not one: something scrolls, and it reaches the end. A container that scrolls 40px of a 400px overflow passes the first and fails the user.

⚠️ Give the harness a realistic viewport height. The bug is invisible at height: 900 — which is what the LMS probes had been using, because the interesting question was always horizontal. 740 is a phone; 900 is not.

⬜ Owed: run this across every shipped module

Sixteen modules have been opted in and only the sixteenth was checked this way. Any module whose top-level container is a plain div — rather than a display: flex shell that had to be reasoned about — is a candidate.

🔴 This came due in #1473. War Room shipped as LAYER 19 — ten layers before this check was invented — and its conversation could not be scrolled at all. Ed found it, not the sweep. The candidate list is wider than the note above says: any flex item between the shell and the scroller that carries min-width: 0 but not min-height: 0 will do the same thing, and an overflow-y: auto further down does not save you, because an overflow rule on a box already as tall as its content is inert.

🔴 And again in 3.7.0: Assets → Leases. The lease page's .ls-wrap is a plain padded div that was never handed the scroller role, so a lease could not be read past the fold on a phone. Ed found it. Fixed in leases.css with the rule above, under @media (max-width: 768px) - the layer's breakpoint, not the module's own 700px one, because the cause starts at 768. Measured with the check above: before, no scroller and a 1,299px page in a 740px body; after, .ls-wrap scrolls 559 of 559.


29. ⚠️ A third-party widget has its own modes — exempt them from your sizing

The mobile layer routinely overrides a mounted widget's geometry, and §25's runtime-stylesheet note says those rules usually need !important to win at all. The corollary took a full-screen button to notice:

!important beats the widget's own modes too.

TinyMCE's fullscreen plugin fixes the editor to the viewport and sizes it with its own height. The LMS layer had:

[data-lms-page="text"] .tox-tinymce { height: auto !important; flex: 1 1 auto; min-height: 240px; }

…which answers a different question — how tall the editor should be while it is one of two things stacked in a flex column. Measured at 360×740 with full screen on: position: fixed, at 0,0, 360 wide and 240 tall. Full width, a fifth of the height, because min-height was then the only declaration left with an opinion.

/* scope the everyday rule out of the widget's own state… */
.tox-tinymce:not(.tox-fullscreen) { height: auto !important; … }
/* …and answer the state explicitly */
.tox-tinymce.tox-fullscreen { height: 100dvh !important; max-height: 100dvh !important; min-height: 0; flex: none; }

⭐ 100dvh, not the plugin's 100% — same reason as everywhere else in this file: on iOS the static viewport is not the one you can see.

And your injected chrome will paint over it

A full-screen widget is a position: fixed overlay with a z-index it chose. So is every action bar this rollout injects. TinyMCE's is 1200; .lms-action-bar is also 1200 — and being later in the document, it won, covering the bottom of the editor.

body.tox-fullscreen .lms-action-bar,
body.tox-fullscreen .lms-pane-tabs,
body.tox-fullscreen .lms-panel-btn { display: none !important; }

🔑 Anything the layer pins to the viewport needs an off switch for every other thing that fills the viewport. Most widgets set a class on <body> or <html> when they take over — key off that rather than trying to out-stack them.

🔴 The part worth sitting with

The rule that broke full screen was written three items after the change that moved the full-screen button to the front of the toolbar on a phone — a change made because it is the one control that rescues a small editor. The control was made easier to reach in the same round that stopped it working, and nothing measured it, because the two changes were filed as different concerns.

⭐ When a round makes a control more prominent, that control has earned a test. Promoting it is a claim that it matters.


30. 🔴 A clipping body cannot report an overflow

§28 found that the vertical question had never been asked. This is its horizontal twin, and it is worse in one specific way: the check that does exist cannot fail, whatever the page does.

CMDB's browse page opens with:

body { overflow: hidden; height: 100vh; }

docScrollW === innerWidth is now a tautology. A document that clips has no scroll width beyond its client width, so the assertion this whole rollout leans on returns contained for every possible layout — a correct one, a squeezed one, or one three times the width of the screen with two thirds of it thrown away.

The page measured clean at 360px on every run. What it actually was:

.browse-sidebar   x=0    w=260
.browse-main      x=260  w=100     <- the object list, on a 360px screen
.main-header      x=260  w=100

A fixed 260px sidebar taking 72% of the viewport, and 100px left for the content you came for. The same fault as the Forms approvals sidebar — except that there the page could at least be caught being wrong.

The check

Stop asking the document and ask the boxes:

// at 360×740, on the REAL page
for (const sel of ['.x-sidebar', '.x-main', '.x-header', '.x-list']) {
  const r = d.querySelector(sel).getBoundingClientRect();
  console.log(sel, 'x=' + Math.round(r.left), 'w=' + Math.round(r.width), 'right=' + Math.round(r.right));
}

Two things to assert, neither of them about overflow:

  • no pane holding content is narrower than roughly 60% of the viewport — a 100px column is not a layout, it is a symptom;
  • nothing has a right beyond innerWidth — on a clipping page that is content which does not exist, rather than content you can scroll to, which is strictly worse than an overflow.

⚠️ Where to look before measuring anything

grep -n "overflow: *hidden" <module>/*.php

overflow: hidden on body or a top-level container is common and usually deliberate — it is how an app-shell layout stops the whole page scrolling behind a fixed pane. It is not a bug. But it silently disarms the containment check for that page, so any page carrying it needs the pane measurement and cannot be signed off on the width assertion alone.

🔑 The general form, and it now has two instances: an assertion that cannot fail is not passing, it is absent. §28's version was a document pinned to 100dvh, so it could never be taller than the viewport; this one is a body that clips, so it can never be wider. Both report success eagerly, and both do it most confidently on the pages that are worst.

⚠️ And distrust a NEW probe on its first red, not its tenth

The existing rule is suspect the probe when it flags code that has already had a device pass. The CMDB round added the other half: of the faults a freshly written probe reported, three of four were the probe's own — a guessed class name for the injected hamburger, headless Chrome not advancing CSS transitions so a drawer read as frozen, and a table flagged as overflowing when the table itself was the scroller. Each cost a round of diagnosis. A probe earns trust by agreeing with a known-good control page first.

🔴🔴 And the corollary the Reporting round added: a NEGATIVE coordinate

The two assertions above both ask about the right‑hand edge. Reporting's landing page slipped past both, and it is the worst instance of §30 so far:

.reporting-landing   w=360  scrollW=530  overflow: hidden/hidden
.landing-content     left=-170  w=700
.report-card[0]      left=-170  w=217   <- entirely off-screen
.report-card[2]      left= 313  w=217   <- clipped at 360
docScrollW = 360                        <- green

Three cards on a justify-content: center row, 700px wide, centred in 360. A centred box that is wider than its container overhangs symmetrically, so the first card was at x = −170 — not truncated, not scrollable to, simply not on the screen. The page reported itself perfectly contained because the container clips, and both the pane‑width check and the right > innerWidth check passed: nothing was narrow, and nothing extended past the right edge.

🔑 Add left < 0 to the pair. right > innerWidth finds content pushed off the end of a row; it cannot find content pulled off the front of one, and justify-content: center on any row wider than its container produces exactly that. The two failures look nothing alike in the numbers and identical to the user.

⭐ The question that would have found it without any check at all is the one §30 already recommends and this round proves the value of: is anything still laid out in more than one column at phone width? .report-cards w=700 in a 360px screen answers it in one line, whatever the coordinates say.

🔴 A labelled feed whose <table> does not exist at load

§21's harvester observes each feed's table so a re‑render cannot undo the labels. Every feed up to LAYER 33 shipped its <table> in the markup and filled the tbody from a fetch, which made watching the table both correct and the tightest scope available.

Reporting has three feeds that do not work that way: both log tabs and the Intune drill‑down replace their container's entire innerHTML, table and all. So at load:

var el = document.querySelector(FEEDS[i].table);   // null — the table isn't built yet
if (el) watched.push(el);
...
if (!watched.length) return;                       // the whole IIFE gives up

The feed then renders perfectly, forever, without a single label, and nothing anywhere says why. That failure is indistinguishable from a cache that did not bust and from §9 load order — the two things you would check first, both of which come back clean.

The fix is an optional watch selector per feed naming a box that is present at load (#logsTableContainer, #drillBody); without one the behaviour is unchanged, so no earlier feed is affected.

🔑 The check that catches it is counting stamped cells, not looking at the feed. document.querySelectorAll('td[data-mobile-label]').length — 150 on one tab, 100 on the other, 100 in the drill‑down — is a number that cannot be satisfied by a feed that merely looks right. Run it against an already‑shipped feed in the same pass as a positive control.

⚠️ The harness can lie in the direction this section does not warn about

Don't explain away a screenshot is the right default and has earned its place. The Reporting round produced the inverse, and it is worth recording because the artefact is a perfect impostor of a real bug.

The first screenshots of the landing page showed every card's text cut off mid‑word at the right edge, while getBoundingClientRect insisted the cards were 332px wide inside 360. Then:

--window-size=360,740   ->   innerWidth = 504
--window-size=400,740   ->   innerWidth = 504

This Chrome build ignores --window-size for the layout viewport entirely, old headless included. It sets the capture rectangle and nothing else. Every direct screenshot was therefore a 504px render cropped to the width asked for — which is pixel‑for‑pixel what content overflowing looks like.

Screenshot through an iframe with an explicit width instead: that is a real viewport of that width, whatever the capture window is doing. Two artefacts of the same family, both of which read as "the feature does nothing":

  • behavior: 'smooth' never advances under --virtual-time-budget, so a guide's contents strip measures and photographs as inert. Settle it by intercepting the call rather than the result — patch Element.prototype.scrollTo on the frame's window and record what the handler asked for. Here it proved the handler fires and asks .help-container for top: 3796.
  • A modal that fades in via opacity / visibility photographs blank. Inject *{transition:none !important;animation:none !important} before the click. A modal that toggles display is unaffected, which is why one of the two dialogues in this module photographed and the other did not.

🔑 A harness is a client, and when one client passes while another fails, it is the client. The way to tell a harness fault from a page fault costs one run: point the identical check at something already known to work. An already‑shipped guide gave the identical 0 → 0, which settled it before any code was touched.

🔴🔴 The third form: a module with no ROWS

§28's version of this is a document pinned to 100dvh, so it can never be taller than the viewport. §30's is a body that clips, so it can never be wider. The System Wiki round produced the third and the easiest to walk past: every wiki_* table on the install was empty, because the codebase had never been scanned. All eight pages drew "no scan data", the probe came back clean on every one, and the whole run was worthless.

The other two announce themselves once you know the shape — an overflow: hidden in a grep, a 100dvh on a shell. An empty module announces nothing: the page renders, the layout is valid, the checks are green, and it looks finished rather than untested.

⭐ The fix is the harness upgrade the Calendar round introduced, taken one level further. These pages take everything from api/wiki/*.php, so:

  1. stub fetch on the frame's window with fixtures;
  2. re-run the page's OWN loaders (loadFiles(), loadTables(), loadHistory()) — the real renderers do the rendering, only the transport is fake;
  3. write nothing to the database.

⚠️ Two costs, one round each, and both look like page faults:

  • A fixture has to speak the app's own vocabulary. table.php groups references by typeOrder = ['SELECT', 'INSERT', …]; a lowercase fixture matched no group and the reference lists silently never rendered at all.
  • And it must not invent values the app cannot produce. A screenshot showed a css file badge with no pill, which read exactly like the unstyled-badge bug found in Reporting the same week. It was the fixture: the scanner is $IncludeExtensions = @("*.php", "*.js") and writes 'PHP' or 'JS' and nothing else, and the page styles exactly those two. Reporting it would have been reporting a fault in my own test data — §18's suspect-the-probe rule, applied to the data rather than the measurement.

🔑 Before writing a single rule for a module, ask what is on this page right now — and if the answer is "nothing", that is the first thing to fix about the test, not a licence to proceed.

🔴 A shared class shares a DESIGN, not only a fix

LAYER 35's headline was the happy version: .dashboard-page and .widget-grid are the same class names two earlier rounds had already answered, so Reporting's Intune dashboard inherited a whole treatment for free. This is the same coin's other face.

The System Wiki's scan page uses .history-table. So does the Assets history trail, and LAYER 15b's card feed for it is entirely unscoped. The moment the wiki opted in it inherited half of that feed from a module it has nothing to do with:

.history-table  w=320  rowH=170  every <th> measuring 0

display: block on the rows and cells, thead { display: none } — and no labels, no card, and seven values stacked one per line with four of them bare numbers. Contained, so no width check could see it; a plausible-looking layout, so no glance would either.

🔑 The tell is a <th> measuring 0. A column width of zero on a table you have not touched means somebody else's thead { display: none } is already applying to it. It costs one line in a probe and it is the only cheap way to find this.

⬜ And the fix is usually NOT to re-scope the old rule. 15b is correct for Assets, and its own comment records that it deliberately avoids column indices because Assets has two variants of that table — so narrowing it risks a shipped module to repair a page it was never aimed at. Take what it already did right (the blocks, the hidden head) and add, under a page marker, the two things it could not know: the card, and the labels. The head is still in the DOM, which is all §21's harvester needs — hiding a head is not the same as removing it.

⚠️ The desktop control has a blind spot, and it is mobile.js

The three-line control — snapshot every element's box, disable mobile.css in place, snapshot again, compare — is the strongest proof available that a round did not touch the desktop. It has one gap, and anything that injects chrome falls straight into it.

Both snapshots are taken after mobile.js has already run. So a node that file injected is present in each, differs in neither, and the control returns a confident identical while the desktop DOM carries a sub-bar and a full-screen sheet it never had before.

The separate assertion is short and belongs beside the control wherever a round adds chrome:

// at DESKTOP width
sub-bar   display === 'none'
sheet     display === 'none'
sidebar   is back inside the container, not left in the sheet
shell     back to the page's own height / flex / overflow

🔑 A control proves exactly what it varies. Toggling the stylesheet varies the stylesheet; it says nothing about the script. The sync() on matchMedia('change') that puts everything back is the thing under test, and only a desktop-width read of the injected nodes tests it.

⚠️ :nth-of-type counts TAGS, and a positional selector on an optional section is worse than wrong

Two mistakes in one selector, and the second is the interesting one.

.section:nth-of-type(1) reads as "the first section". It means "an element that is the first <div> among its siblings and has class section" — and on this page the first div is the breadcrumb, so it matched nothing at all. Measured: every cell still order: 0, six tables still tables, a whole layer with no effect. §9's symptom with a completely different cause, which is exactly why it is worth naming: the instinct is to blame load order or a cache.

The obvious repair is :nth-child(3). That would have been worse than wrong rather than merely wrong, because the Classes section is rendered only when the file has classes — so a positional selector points at a different table depending on which record you opened, and it would have looked right on every file that happened to have both.

⭐ The answer was to key on the thing that decides the treatment: how many columns the table has.

.detail-table:has(thead th:nth-child(5))                      /* Functions  */
.detail-table:has(thead th:nth-child(4))                      /* + Classes  */
/* three columns matches neither, and is left alone */

§11's decision was made on column count and content, so the selector now says the same thing the comment says, and it survives the conditional section, a reordering, and a seventh section being added later.

⚠️ A postscript from the same block: table-layout: fixed was added to make one long file path wrap, and it turned the four tables that already fitted into three equal 112px columns — Type and Line handed as much room as a path. overflow-wrap: anywhere was the whole fix: it drops a cell's min-content width to a single character, which is all the auto layout ever needed in order to be allowed to shrink it. Reach for the property that removes the constraint, not the one that replaces the algorithm.

🔴 A flex: 1 is measured against its own parent — print the WHOLE chain

Giving a shell the remaining height is the most repeated move in this rollout: make the ancestor a flex column, give the scroller flex: 1 1 auto; min-height: 0; overflow-y: auto. The portal's guide is where that failed in a way worth recording, because the rule was right and did nothing.

DIV.help-container   flex: 1 1 auto   height 7069px   <- resolved
DIV.portal-layout    display: block   height 7069px   <- against THIS
BODY.portal-help     display: flex    height  740px

<body> was a flex column and .help-container asked for the remainder — but .help-container is not a child of <body>. The portal's header include wraps every page in a .portal-layout, which stayed a block, grew to its content, and handed 7069px down as "the remainder".

🔑 Every element between the viewport-height box and the scroller has to pass the height down. One block in the middle absorbs it, and the symptom is a flex: 1 1 auto that computes exactly as written while the page still cannot scroll.

⭐ Measuring the container alone said flex: 1 1 auto; min-height: 0; overflow-y: auto — all three correct — and looked like a fix that should have worked. What found it in one run was printing the entire ancestor chain, innermost first, with display, height, min-height, flex and overflow for each. That is now the first thing to print when a scroller will not take, and it is four lines of probe:

var n = scroller;
while (n && n.tagName !== 'HTML') {
  var c = getComputedStyle(n);
  log(n.tagName + '.' + n.className + ' {display:' + c.display + '; height:' + c.height +
      '; minH:' + c.minHeight + '; flex:' + c.flex + '; ovf:' + c.overflowY + '}' +
      ' box=' + n.getBoundingClientRect().height + ' scrollH=' + n.scrollHeight);
  n = n.parentElement;
}

⚠️ min-height is inert on an inline box — and the tap-target fix is the display type

min-height: 24px on .ticket-link had never applied to anything. An <a> wrapping text is display: inline, and an inline box's height comes from its line box and nothing else, so the declaration computed correctly and changed nothing — measured at 90×17.

This is the same family as §16's width does nothing on an inline box, and it is the third rule found inert in that one stylesheet, after a padding that could not beat a min-width floor and a .col-optional class that appears nowhere in the markup.

🔑 When a tap-target rule has not worked, check the display type before raising the number. And ask whether the row is tappable at all: here the <tr> had no handler, so a 17px line of text was the whole target on the page a requester lands on — which is what made a cosmetic-looking number into the thing worth fixing.

🔴 …and then the fix looked broken, which only a screenshot showed

The obvious repair — display: flex; min-height: 44px; width: 100% on .ticket-link — is wrong, because there are two anchors per card (the reference and the subject, in separate cells). Forcing a 44px block on each added ~50px of empty space to every card and left the reference floating above a gap.

Every measurement improved: contained, no overflow, two comfortable targets, correct order. The card looked broken.

🔑 Put the height where the TARGET is, not on everything that could be one. The subject is what a person aims at, so it takes the full-width 44px row; the reference above it is a short identifier and takes a 32px inline box. One generous target per card, and the card keeps its shape.

⭐ The general form, and it has now happened twice in a week — the other was Refresh as a full-width accent slab in Reporting: a tap-target or emphasis rule applied uniformly will pass every check and read wrong. There is no measurement for proportion. Take the screenshot.

31. 🔴 A chooser must not be made of the things it filters

The rota is 160px repeat(5, 1fr) - six columns at phone width, which §11 says a sideways scroller cannot help. Ed's answer was better than picking one reading for everybody: keep all three - one day, one analyst, the whole grid - and flip between them from a sticky footer, because which one you want is a personal preference.

The filtering was the obvious part. In day view, show the name column and the chosen day; in analyst view, show the chosen analyst's row. The grid's own cells were the chooser: tap a day heading to change day, tap a name to change analyst. Both are already in the markup, both already translated, and §21 says to prefer what is there.

And that is the bug. The rule that hid the other days hid the other day HEADINGS with them, and the rule that hid the other analysts hid their name cells. Each view came up showing exactly one choice, with no way to reach another. Ed found both within a minute of opening them.

🔑 If the chooser is made of the things being filtered, the filter eats the chooser. Put it somewhere the filter cannot reach - a strip above the grid, labels harvested from those same cells rather than made of them. Harvesting keeps §21's benefit (no new strings, right in all 24 locales) without the coupling.

⭐⭐ And the probe said all three views were working

This is the part worth keeping. The probe was not lazy. For each view it checked the document did not scroll sideways, that no box clipped content wider than itself, that grid-template-columns had actually changed, that the empty state had not been swallowed, and that the tags survived a week change. Every one passed, in all three views.

It never asked "can you now change which day?"

// what it measured                    // what it should also have measured
cellsVisible: 16                       const before = shownDates();
gridCols: "104px 224px"                chip(2).click();
emptyStateKept: true                   assert(shownDates() !== before);

Both checks are one line. The first describes the screen; only the second describes the feature. This is §28's shape in a third place: an assertion that cannot fail is not passing, it is absent - and a suite of them reads as thorough.

🔑 For anything with a state the user chooses, the test is: change the choice and assert the screen changed. Counting what is on screen in one state cannot see a control that does not work, because a broken chooser and a working one render identically until something taps them.

⚠️ Related, from the same round: when the probe did eventually report something - the day chips as untappable - three re-measurements showed all five reachable. It had measured chip 0 after a scrollIntoView moved it. §30's "distrust a new probe on its first red" held again: of the two reds a fresh probe produced this round, one was real and one was its own.

The relocation, because it is the other half

Ed also asked for Copy week in the footer. Move the real button, never a copy of it. rota.js finds #rotaCopyWeekBtn and #rotaPasteWeekBtn by id and toggles Paste's visibility, so a clone leaves it talking to the one still in the header - and two elements share an id. Same move as the calendar's 16a: the node goes, and it goes home again when the viewport leaves mobile.

That makes the footer two rows, and its height stops being a constant because Paste appears only once a week has been copied. Measure it into a custom property and let the container reserve exactly that:

document.body.style.setProperty('--rota-bar-h', Math.ceil(bar.getBoundingClientRect().height) + 'px');
padding-bottom: calc(var(--rota-bar-h, 108px) + 10px + env(safe-area-inset-bottom));

A hard-coded clearance is wrong in whichever state you did not measure, and being wrong hides the last row behind the bar.

⚠️ And check the padding against the other modules, not against the page

The rota's panels looked over-padded to Ed, and they were: .rota-container was still on the desktop's 20px and .rota-header on 15px/20px. The house figure is 12px - .knowledge-main, .review-container, .ka-page and .servers-content all use it, and .wr-main uses 10px/12px. None of that is discoverable from the page you are working on, which is exactly why it drifts.


32. 🔴 A pane with no width — and the fix that moves nothing

Tapping a user in Tickets → Users did nothing. So did tapping a group. No error, no blank screen, no flicker — the row highlighted and the screen did not change.

Both were working perfectly. Measured immediately after a tap:

.user-detail-container   x=360  w=0  h=689   textContent: 1748 characters

The fetch ran, the markup arrived, 1,748 characters of it, and it was all rendered into a box zero pixels wide, parked at x=360. The pane is flex: 1 1 0 beside a list that is width: 400px; min-width: 300px, so on a 360px screen the list takes everything and there is nothing left.

🔑 Nothing was broken, so nothing could be found by looking for a break. Every check this rollout owns asks whether something is wrong with what is on the screen. This was right, in a container with no width. The question that finds it is the §30 one — is any pane holding content narrower than about 60% of the viewport — and it is the third time that question has been the only one that would have worked (Problem Management's 110px list, CMDB's 100px browse pane, this).

The shape to recognise: a fixed-width pane beside a flexible one, on a screen narrower than the fixed pane. It has now appeared five times, and the treatment is always the same — stack them, cap the chooser, and hand the scroller role to the content pane explicitly (§28).

⭐ And the part that is easier to get wrong than the fix: the order

Checklists' list page had three faults stacked on top of each other, and each one hid the next.

.chk-grid   { grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); }
.chk-layout { display: flex; }           /* with a 260px flex-shrink: 0 sidebar */
.chk-card   { /* a grid item, so min-width: auto */ }
  1. minmax(340px, 1fr) reads as at least 340. On a 360px screen auto-fill lays out a 340px track anyway, so the cards overflow. A genuine fault. Fixed first — and it changed nothing measurable.
  2. Because upstream, .chk-layout is a flex row whose 260px sidebar carries flex-shrink: 0, leaving .chk-main one hundred pixels wide. A 1fr track inside a 100px box is 100px, and the cards overflow exactly as before.
  3. And with both fixed the card still sat 489px wide in a 336px track, because a grid item has min-width: auto as well — §14 on a grid rather than a flex row. The track was right and the item ignored it.

🔑 When a fix that should have moved something moves nothing, look OUTWARD before you look again at the rule. §9 teaches this for load order — a rule that is merely wrong usually moves something; a rule that never applies moves nothing — and the same instinct applies one level up: a correct rule inside a container that is already the wrong size also moves nothing. Both times the temptation is to re-read the rule you just wrote.

⚠️ And that page had a load-order fault too: mobile.css was linked above the page's own <style>, so every same-specificity rule in it was losing on document order. The module was contributed with mobile.css already linked, which is why three of its four pages measured clean and nobody looked at where the link sat.


33. ⚠️ Four ways a probe reports a page it never measured

Every one of these happened in a single round, and each produced a green result on a page that was wrong.

1. A server error page is served AT the path you asked for

tickets/csat/survey.php returns 404 by design — /csat is the canonical URL and a direct hit would be a third URL for the same page. The probe asked for the file path, got a 404, and reported no blocking fault, because its "did the right page load?" check compared paths and the path matched.

res.wrongPage = landedOn.split('?')[0].indexOf(want) === -1;   // a 404 passes this
res.serverErrorPage = /\b(404|403|500)\b|not found|forbidden/i.test(title);   // add this

2. A module with data, but none in range

§28 already covers a module whose tables are empty. This is the subtler cousin: CSAT had data — two responses, both with comments — and its default window is 30 days, and both were months old. So every panel rendered its empty state and the page measured perfectly clean. ?days=365 brought them in and the real fault appeared immediately.

🔑 A filtered view with nothing in the filter is indistinguishable from a finished one. Check what the page's default range actually includes before trusting a clean run.

3. The checker falls for the exact thing it was written to catch

§11 says plainly: overflow gets reported, crushing does not. So the probe grew a crushed-cell check — and CSAT's comment column, 57 pixels wide, walked straight past it, because the check demanded a cell more than 60px tall and a 57px comment that wraps to 34px is already unreadable.

🔑 A threshold copied from one example is a description of that example. The width alone was damning; the height was incidental to the case the rule was written from.

4. A mention is not a use

tickets/users.php shipped with the marker and the script and no stylesheet, because the file contains this comment:

// the `.modal-content .form-row` rule at mobile.css:223 sets a GRID

A survey looking for the string mobile.css counted the page as converted. So did the opt-in tool, which then silently skipped the link. Both now look for <link[^>]+mobile\.css.

This is the same fault as sslApplyCurl() appearing inside a description string in the debug-tool registry (§31 has the tokeniser version). Grep finds text; the question is almost always about code.

⚠️ A related trap in the tooling itself

The marker was inserted with <body([^>]*)> and the whole tag rewritten. Checklists opens with PHP inside that tag, so [^>]* stopped at the first > — the one closing the PHP — and three files were left unparseable. Insert after the literal <body and the tag's contents stop mattering.

And a PHP // comment does not protect a close tag: writing that explanation into the tool's own source broke the tool.


Reference

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.
LAYER 19 War Room — the chat: channels across the top, the feed in the middle, the composer pinned at the bottom. 🔴 And for a long time it could not be SCROLLED (#1473): .wr-main had min-width: 0 and not min-height: 0, so on a phone — where LAYER 2 makes the height content-driven rather than the desktop's fixed calc(100vh - 48px) — it refused to shrink below its messages and the feed grew to 9,011px. The feed's own overflow-y: auto was there the whole time and had nothing to do. min-width: 0 and min-height: 0 are two different guards, and seeing the first is what makes the missing second easy to walk past. See War Room
LAYER 20 Inbox row chips — placements that cost no width survive intact; ones that cost width degrade to their quietest form (a pill collapses to its dot, a name ellipsises). Nothing is hidden outright, so a chip somebody deliberately switched on never silently disappears.
LAYER 21 Watchtower — stacked top bar, single-column card grid, card metrics as an aligned grid rather than a wrapping flex row (§10) — see Mobile: Watchtower. No mobile.js branch.
LAYER 22 Problem Management — sidebar → header strip, status filters as a chip row, the module's own .pm-modal-content promoted to a sheet (LAYER 3 only knows .modal-content), and the sidebar stepping aside while reading — see Mobile: Problem Management
LAYER 24 Software — toolbars stacked (a width: 350px search box on a 360px screen), licences and the widget library as card feeds, settings padding trimmed. Its biggest table needed nothing: .software-table is Asset Management's class too, so LAYER 15a's feed already covered it (§19 — I misread the measurement and wrote a duplicate first). The library toolbar was the bug §18 is about — see Mobile: Software
LAYER 23 Change Management — sidebar → header strip (with order to beat the DOM sequence), the approvals sidebar as a single scrolling row, and change cards stacked because flex-shrink: 0 on their badges made them wider than the screen, plus the rich-text editor's min-width: auto shrink floor and a full-screen panel over its tab strip, and its padding trimmed via a marker class because .editor-* is shared with Knowledge (§15), then its six rich-text fields turned into cards with one editor built on demand (§16) (§14) — see Mobile: Change Management

| LAYER 25 | Tasks — the Kanban board as one status per screen with scroll-snap and a dots indicator, the list as a card feed, and the detail panel's native date field (§20) — see Mobile: Tasks | | LAYER 26 | Morning Checks — the seven‑column checklist as a card feed with the status buttons full width, and the module's own pre‑rollout @media block removed and folded in: before styling anything for a phone, find out what is already styling it (§15) | | LAYER 27 | Forms — all seven pages. Two faults that no containment check could see: a submissions page 1,400px wide inside a scrolling container that made the document measure clean, and an approvals sidebar that left the content 100px wide with headings at clientWidth: 0. Plus the first card feed to keep its column headings (§21) and the correction to what "drag is out of scope" actually means (§22) — see Mobile: Forms | | LAYER 28 | Contracts — the largest round, 22 pages including the RFP Builder. An Overview / directory switch on two list pages, the 260px sidebar as a chip strip, six card feeds beside four scrollers, and nine module modal shells promoted by keying on the three overlays rather than the nine cards (§23, §24, §25, §26, §27) — see Mobile: Contracts | | LAYER 29 | LMS — four tables answered three ways and then one of the answers reversed (§11), a lesson split into two pages with its actions relocated to a bottom bar, the Progress filters behind one icon, and two faults nothing had ever checked for: a page that fits the width but cannot be scrolled (§28) and a widget's own full-screen mode losing to this layer's sizing (§29) — see Mobile: LMS | | LAYER 30 | CMDB — the sixteenth module, and the first that mostly arrived ready: the object page already carried its own @media (hover: none) touch guards and a stacking breakpoint, and three of five pages needed nothing past the shell. The browse page's 260px class sidebar left the object list 100px wide, which the containment check could not have caught because that page sets body { overflow: hidden } and a clipping document can never report an overflow (§30); it became a chip strip. Then a screenshot found the empty state still pointing "on the left" after the classes moved to the top — and on the next pass, that the direction-free replacement said the same words twice — see Mobile: CMDB | | LAYER 31 | Network Mapper — the seventeenth module and the second canvas, where the 240px class palette left the diagram 119px wide and the fifteen-button toolbar crushed the diagram's own title to 4px — a case flex-grow cannot fix, because a grow factor is inert when the other items already fill the line (§23). Its palette drag is HTML5 and would have worked on touch (§22), and was declined at first because you could place a node and never move it again — the missing half being the recovery, not the action. Ed then asked for it, and the answer was all three: place, move and delete, since deleteSelectedNode() is bound to the Delete key alone. Not one line of the module's JS changed — each gesture ends by dispatching the event it already listens for (a drop with a DataTransfer, a mousedown/mousemove/mouseup, a keydown), which is §1 applied to a feature. LAYER 30f's 58dvh detail sheet copied straight over left 71px of map, because this module has 240px of chrome above it rather than 48 — a dvh split only works when the two halves are the whole viewport. Opting its guide in found two faults outside the module: the guide could not be scrolled at all (§28, a third file — help.css's own 900px block — handing the scroll to a document that clips), and the numbered contents strip has been inert on a phone in all seventeen guides since LAYER 16, because each page scrolls #helpMain, which is overflow-y: visible below 900px. Three of four probe reds were the probe's own (§30's addendum), and the one real fault was found by looking: a full-width Delete on a card that is itself a tap target — see Mobile: Network Mapper | | LAYER 32 | Workflow — the eighteenth module and the third canvas, but the first fully usable on a phone, which is a fact about the module: its nodes are added by toolbar buttons, auto-stacked in one column (COL_X), configured in the panel and deleted with a real button, so dragging one is decoration (§22's follow-up question — what is the drag FOR?). The editor's 340px inspector is never closed, leaving the diagram 20px — the narrowest pane in the rollout. It now stacks map-over-inspector, split as a percentage of the pane holding both rather than in dvh, which is LAYER 31f's lesson used rather than repeated. Opting the list pages in made them worse first: .container grew 360 → 705px because LAYER 2's flex body turned it into a flex item with a 637px table for a floor (§14), and the pin that fixes it existed only under data-mobile-page="settings". Two card feeds, a 538px filter stack halved, and §20 for the sixth time. All three probe reds were the probe — see Mobile: Workflow | | LAYER 33 | System: webhook deliveries — the page the Workflow round missed, because Workflow's own nav links to it and it lives in system/. A nine-column delivery log at 556px on a 360px screen, which Ed saw as spilling headings even with an empty log (a table with no rows is still as wide as its <thead>). 🔴 And no sweep had ever reported it: the page's shell declares only overflow-y: auto, so §12 computed overflow-x to auto and the container absorbed the table — §18's hidden-bug case in its worst form, since the whole PAGE dragged sideways and nothing said so. Now a card feed with §21 labels on six of nine, overflow-x: hidden on the container so the next one cannot hide, and the two table.mini summaries left as tables — see Mobile: Workflow | | LAYER 34 | System — the nineteenth module and the largest round: 53 pages, 34 file edits, because three shared shells carry 43 of them (the 14 debug tools are stubs behind one renderer, the 19 help topics fragments behind one _top.php). 🔴 The landing page could not be scrolled at all — 5008px of cards — because LAYER 2's .main-container { overflow: hidden } beat the overflow-y: auto the page had declared for itself, on load order, by design (§9 cuts both ways). Config tables scroll sideways (Ed's #1004 rule); the 20 guides' tables become stacked blocks, because their last column is a sentence (§11). Branding: a bare 1fr track at 1282px inside 264 (its own breakpoint dropped the minmax(0, …) the desktop rule had) and six inputs at 55px — contained but crushed. And the desktop control done properly for the first time: toggle mobile.css off in place, diff every element's box, identical — see Mobile: System | | LAYER 35 | Reporting — the twentieth module and the smallest round, five pages, and the strongest case the rollout has made for shared layers: the drawer (LAYER 1), the JSON sheet (LAYER 3), the whole Intune dashboard shell and one-chart-per-row grid (15d, because .dashboard-page / .widget-grid are the same class names Assets and Software use) and the guide's scroller plus its contents-strip fix (16h + #1464) all arrived free — which also retired an unfired calc(100vh - 48px) on a page nobody had opened on a phone. 🔴🔴 The landing page was showing one and a half cards out of three: a 700px justify-content: center row inside a 360px container that clips hangs 170px off both sides, so the first card sat at x = −170 — off-screen, unreachable, and docScrollW a perfectly contained 360. §30 in its cleanest form yet, and the lesson sharpened: a card at a negative coordinate is invisible to every check that asks about the right-hand edge; the clip was LAYER 2's own .main-container { overflow: hidden } for the third module running (§9 cutting both ways). Both log tables become card feeds — a 264px row for five short values, because a user agent is prose (§11) — and since both tabs render the same class into the same container while wanting different columns labelled, the renderers now stamp data-log-type: LAYER 33b's read the markup for the selector one step on, if the markup cannot say which table this is, make it say so. These are also the first labelled feeds whose <table> does not exist at load (the container's whole innerHTML is replaced), which made the §21 IIFE find nothing, return silently and never stamp a cell — indistinguishable from a cache that did not bust; FEEDS now takes an optional watch selector, and the check that catches it is counting stamped cells rather than looking at the feed. The Intune drill-down, never a .modal-content, was a 320px floating dialogue holding a 578px six-column table: now a full-screen sheet of cards with Encrypted Yes labelled — the clearest case in the rollout of a cell that means nothing alone, and a card feed chosen over a sideways scroller even though no column holds prose, because the list is read down. minmax(0, 1fr) for the third time (34i). ⚠️ And the round's own harness lied in the direction §8 does not warn about: this Chrome build ignores --window-size for the layout viewport (504px regardless), so every direct screenshot was a 504 render cropped, which looks exactly like text overflowing — screenshot through an explicit-width iframe, and remember that behavior: 'smooth' and opacity transitions never advance under virtual time. The one real fault a screenshot found was a rule of mine: Refresh as a full-width accent slab above the tabs that are the page's navigation, which every measurement passed — see Mobile: Reporting | | LAYER 36 | System Wiki — the twenty-first module, seven pages, and the last one in the waffle menu. 🔴🔴 Every wiki_* table on the install is EMPTY (the codebase has never been scanned), so the first probe run came back clean on all seven pages and proved nothing — §28's an assertion that cannot fail is not passing in its third form, and the easiest of the three to walk past because the page looks finished rather than broken. Fixed by stubbing fetch and re-running each page's own loaders against fixtures shaped like the codebase's worst case — real renderers, fake transport, nothing written. ⚠️ And a fixture has to speak the app's own vocabulary: lowercase reference types matched no group in typeOrder so a whole list silently never rendered, and a screenshot's unstyled css badge turned out to be my fixture inventing a file type the scanner cannot emit (it only ever writes PHP or JS) — reporting that would have been reporting a fault in the test data. ⭐ Six shells, one shape: all six are height: calc(100vh - 48px), so one rule covers eight pages, and six of them could not be scrolled at all before. 🔴 A 280px folder tree beside the file list left .wiki-main at 80px holding a 1110px table — the second-narrowest pane after Workflow's 20 — clipped by two ancestors that both say overflow: hidden, so nothing reported it. The tree goes to a sheet (a tree cannot be CMDB's chip strip: the hierarchy is the information), and 🔴🔴 closing it on every folder tap would have left api/tickets unreachable on a phone, because selectFolder() expands the tapped folder as well as selecting it — so the sheet would shut at the exact moment it revealed the next level down. A parent is a step; a leaf is an arrival, and renderTree only fills .tree-toggle when a node has children, so the markup already says which is which. That fault passed every measurement: the sheet opened, the tree was on screen, the rows were tap-sized — what was broken was the second tap. The per-analyst hover rail is the third module to ship that dead end and the first needing !important: §24, header.php emits its <style> inside <body>, so the specificity route the whole rollout has relied on is closed. 🔴 The scan history had already inherited another module's card feed — LAYER 15b's .history-table is entirely unscoped (§15) — giving it blocks and a hidden head and no labels, seven values one per line with four bare numbers: a shared class shares a DESIGN, not just a fix, and the tell is a <th> measuring 0. A file's page has six tables and takes three answers (§11), keyed on column count via :has() because :nth-of-type counts by tag (it matched nothing) and :nth-child would point at a different table depending on the file, since the Classes section only exists when there are classes. Also an inline white-space: nowrap only !important could reach, and table-layout: fixed making four fitting tables three equal 112px columns when overflow-wrap: anywhere was the whole fix. ⭐ Search's .result-item results were the only list in twenty-one modules that arrived already in the right shape — see Mobile: System Wiki |

Later additions worth knowing about

  • 🔴 A delegated listener must use the CAPTURE phase when the page redraws what was tapped. The Domains register's filter sheet closes when a view is tapped, asked as e.target.closest('#domViews button'). The page's own handler re-renders #domViews to move the highlight, and a bubbling listener runs after it - so the tapped button is already detached and closest() finds nothing: the list changed and the sheet stayed open over it. addEventListener('click', fn, true) runs before the page's handler. Found only by tapping it (§31's test); every measurement of the open sheet passed. See Mobile: Domains.

  • ⭐ A forced line break in a flex row: a zero-height, full-width item placed by order. tr::after { content: ""; order: 5; flex: 0 0 100%; height: 0; } - everything with a higher order starts at the left of a new line, whatever the content before it did. §23's middle basis settles ONE line that depends on the data; when two do (a short name, then a long status), this is the deterministic answer. Three screenshots of the Domains feed passed every measurement and showed two card shapes until it went in.

  • 🔴 overflow: hidden stops the USER, not scrollTo(). Four of the five People pages could not be scrolled by a finger: inbox.css gives <body> overflow: hidden, and with <html> at visible that propagates to the viewport. Yet window.scrollTo(0, 99999) moved the page to scrollY = 8972, header and all. A probe that scrolls by code and asserts the position changed passes a page nobody can scroll. Assert on an element whose computed overflow-y is auto/scroll (§28's check) - when none qualifies, that IS the finding. The tell in one line: getComputedStyle(body).overflowY === 'hidden' with documentElement.scrollHeight > innerHeight.

  • ⭐ Split the BOX that holds the two panes, not the viewport — the dvh note below, one round later and done right. The Workflow editor needed the same map‑above/panel‑below shape, and its toolbar wraps, so the chrome above is not even a fixed number. .wf-main was already the box holding exactly those two panes: flex-direction: column on it, flex: 0 0 34% on one and flex: 1 1 auto on the other, and there is no arithmetic to get wrong — right at any height, in any locale, whatever the bar above does. Reach for dvh only when the two halves really are the whole screen.

  • ⭐⭐ The strongest desktop control available is three lines, and it should be routine. Every round until System compared before/after measurements by eye. Instead, toggle the layer off in place and diff a fingerprint of every element: const a = fingerprint(doc); [...doc.querySelectorAll('link')].forEach(l => { if (/mobile\.css/.test(l.href)) l.disabled = true; }); const b = fingerprint(doc); a === b — where fingerprint is every element's left,top,width,height. On six System pages at 1100px it returned identical, which is the one hard rule proven rather than argued. It also cleared a false alarm the page sweep had raised at desktop width, which comparing numbers by eye could not have done.

  • 🔴 §9's load-order rule cuts BOTH ways. mobile.css goes last so it wins the ties it needs to win — and it therefore also wins ties it should lose. LAYER 2 gives .main-container overflow: hidden for the ticket pane stack; three System pages had each declared overflow-y: auto on that same container for themselves, and opting them in switched their scroller off — the module's landing page became 5008px of cards with no way to reach any of it. A page that already solved something for itself is not protected by having solved it first, and .main-container is the app's most reused shell name. (LAYER 15f found this once already for .servers-container; scoping the fix on the module marker takes it to (0,2,0).)

  • ⚠️ When a page stacks its own grid at a breakpoint, check it kept the minmax(0, …). Branding's desktop rule is grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr); its own @media (max-width: 900px) collapses that to a bare 1fr — and a bare 1fr track has an automatic minimum of min-content, so the grid measured 264px wide with a 1282px track. §14 seen from the grid side. Going from two columns to one is exactly when the guard starts to matter and exactly when it gets dropped.

  • ⭐ Find the shared shells before estimating the work. System is 53 pages and 34 file edits: fourteen debug tools are five-line stubs behind one renderer, nineteen help topics are fragments behind one _top.php, and five settings screens share a shell. Two greps — the container class per page, and which include renders it — also predict which faults will be systemic rather than per-page. ⚠️ A partial pair is one page across two files: _top.php opens <body> and _bottom.php closes it, so the stylesheet goes in one and the script tag in the other.

  • 🔴 Scope a round by the module's own NAVIGATION, not by its directory. The Workflow round opted in all seven pages under workflow/ and shipped — and Workflow's own header links to system/webhooks/, which lives in another module, renders another module's header, and had never been touched. Ed found it within the hour. No grep of the module's directory could have found it; walking the nav bar the module actually renders would have, in one minute. Open the module on a phone and tap every destination in its own header before calling a round done.

  • ⚠️ A table with no rows is still as wide as its <thead>. The nine-column delivery log above was reported as spilling "when, Workflow, format" — those are the headings, because the log was empty on the reporter's install. Do not wait for data before measuring a table, and do not assume an empty one is contained: the header row alone was 556px.

  • ⚠️ Opting a page in can make it WORSE before it makes it better, and .container is the usual victim. LAYER 2 turns <body> into a flex column, so a top‑level container becomes a flex item — and a flex item's automatic minimum size is its content width. The Workflow list's .container went from 360px to 705px on opt‑in, because it holds a 637px table: before, its own overflow: auto clipped the table; after, the table set a floor and the container grew to meet it. The pin (width/max-width/min-width: 0 + flex: 1 1 auto) had been written for exactly this two rounds earlier — keyed on body[data-mobile-page="settings"], so a list page got none of it. 🔑 "A rule that already covers this is worth measuring" has a second half: a rule that covers the same fault on a different PAGE TYPE. Neither is findable by grep, because the selector you would search for is the one that is missing.

  • ⚠️ A crushed‑cell probe needs §19's shared‑height test, and an overlap probe needs a containment test. Both of mine cried wolf in one round: cells reading 70×68, 79×68, 78×68 inside a working 1307px scroller were narrow only because their content was short ("slack", "Built‑in", "Copy") and 68px tall only because a real table row shares one height; and a <em> "overlapping" its parent .wf-node-title was a descendant, not a sibling. Both reported a fault on code that was correct, one of it on a rule this rollout wrote deliberately. Run every new probe against a known‑good control before believing its first red.

  • 🔴 A dvh split only works when the two halves are the whole viewport. LAYER 30f pairs a 58dvh bottom sheet with the canvas above it, and that is right in Process Mapper, whose bar is 48px. Copied into Network Mapper, whose header, stacked editor bar and meta row take 240px before the map gets a pixel, the same figure left 71px of map — the selected node was in it, so the assertion passed, and there was no room for anything around the node, which is most of why you look at a diagram. Reuse the pattern, re-derive the number, and prefer the form that does not need it: a margin-bottom equal to the sheet's height on a flex: 1 item in a 100dvh column leaves it exactly the rest, whatever the chrome above has wrapped to, in any locale. A max-height is a second guess at the same arithmetic and drifts the moment the bar changes.

  • ⚠️ Making a control bigger also makes it easier to hit BY ACCIDENT. A card action row given flex: 1 1 100% produced a full-width 38px button — 22px → 38px, a tap-target floor being met, and a tap-target check passes it. The button was Delete, on a card whose entire surface is the tap target for opening the record. Every number said improvement. When a control is destructive, or sits inside a larger tap target, size is not the only property it has: give it the height and leave it its natural width. Found by looking at a screenshot, which is now four rounds in a row.

  • ⚠️ A rule that "already covers this" is worth measuring. LAYER 3's 16px anti‑zoom rule (.modal-content .form-input, specificity 0,2,0) had never applied to the typed inputs, because inbox.css styles 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 and mobile.css wins 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 !important beats 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: auto on a table is only half the rule; without white-space: nowrap on the cells the browser wraps every column down to one word per line, which passes the docScrollW === innerWidth check 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 its padding-top, leaving a transparent strip that scrolling text slides through. If a module has a position: sticky header, 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.css carries .modal-content:has(> .modal-body) > .modal-header at 24px, which is (0,3,0) and beats it. Only the modals that had a .modal-body matched, 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 by getBoundingClientRect().left compares 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 own padding-left before comparing.

  • A shared class means a one-page fix is a five-page fix. .tab-content is styled once in inbox.css with padding: 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-shell instead. LAYER 2 makes <body> a 100dvh flex 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. Measuring tickets/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-local sitting at the browser default. Rewritten as input:not([type=checkbox]):not([type=radio])…, a newly added field type is covered by default instead of forgotten.

  • ⭐ flex-shrink: 0 is the commonest reason a row will not fit a phone. It is correct on desktop — it stops a group of badges being squashed into illegibility — and exactly wrong at 360px, where something has to give. Change Management's list cards carried it on their badge group, which made every card wider than the screen no matter what the panes did, and because an ancestor scrolled, docScrollW === innerWidth stayed true throughout. On the page it read as pills marching off the right edge and the assignee colliding with the date. Grep a module's CSS for flex-shrink: 0 before wondering why a row overflows.

  • ⚠️ grid-column: 1 / -1 cannot reorder, only widen. Spanning one child across both columns still leaves it wherever the DOM put it — so two buttons with a full-width section between them land on three separate rows rather than sharing one. Add order to move the spanning child last. The tell is a layout that is correct in every respect except that the pieces are in the wrong rows.

  • ⚠️ When a probe reports a fault on shipped code that has already had a device pass, suspect the probe. A sweep flagged a table as overflowing its parent at 938 > 326; the parent was the scroller, so that is exactly what a working scroller measures. A table is only wrong if it is wider than its box and no ancestor scrolls. Always keep a known-good page in the sweep as a control — the identical reading on asset-management/table.php is what settled it.

  • ⚠️ Opting a page in has THREE parts, not two — and a settings page has four. The two <link>/<script> tags are the obvious pair; the third is mobile.css going AFTER the page's inline <style> (§9); and a settings screen needs <body data-mobile-page="settings">, which is what the entire shared settings layer is keyed on. Problem Management and Watchtower settings were both brought along without it, and a five-column table went on squeezing every cell to four lines while pushing its right-hand columns past the edge of the card. All four are silent when missing — no error, no partial effect, and in this case no failed measurement either.

  • ⚠️ docScrollW === innerWidth does not see a table overflowing its PARENT. The settings table above was 423px inside a 288px panel and every containment check stayed clean, because it was overflowing the card rather than the document. The page was contained; the columns were off the edge of the box they sat in. Compare a table's scrollWidth against its parent's clientWidth, not just the document against the viewport.

  • ⚠️ A module with its own modal class is invisible to LAYER 3. That rule promotes .modal-content to a full‑screen sheet; Problem Management rolled its own .pm-modal-content, so three dialogs stayed as 92%‑width centred boxes with a 40px margin and nothing in the measurements said so — a small centred box on a phone is perfectly contained. Grep the page for its modal class before assuming the shared layer covers it.

  • Not needing JS is not a virtue in itself. Three modules in a row shipped CSS‑only, which makes a fourth branch feel like a failure. The test is what the page does: Problem Management was already master‑detail in its own JS, and the one thing missing — the sidebar stepping aside while you read — was a six‑line wrap of two global functions. Wrap only what the page cannot express in CSS, but do wrap it.

  • ⚠️ Mark state AFTER the async work resolves, not before. Wrapping an async opener to set a "now showing detail" flag up front leaves the flag stuck on when the fetch fails and the module bails — hiding the list behind an error toast with no way back. Resolve the promise first, then check the DOM actually changed before marking.

  • ⚠️ Injected chrome must declare its own colour — never inherit. The views hamburger mobile.js injects was color: inherit, the only control in the header bar that did; .user-avatar and .nb-btn both state their own. Two 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 }, and the glyph came out near‑black on a dark bar. Tappable, and completely invisible. Anything mobile.js injects lands in a page whose tokens you do not control, so it has to state everything it depends on. See §8 for the near‑miss that followed while fixing it.

  • Aligning a marker on a row that can wrap. align-items: center on an icon‑plus‑text row centres the icon against the whole block, so the moment the text runs to two lines the marker floats between them, belonging to neither. Use align-items: flex-start and push the marker down by half a line: with font-size: 13px; line-height: 1.45 and a 6px dot that is (13 × 1.45 − 6) / 2 ≈ 6px. Chosen that way, a single‑line row is pixel‑identical to before, so the change only affects rows that actually wrap — which is what makes it safe to put in the module's own CSS rather than behind the @media gate. This kind of bug is not mobile‑only: the same row wraps on a 404px desktop card.

  • ⚠️ Stacking a grid to one column does nothing if its children grid-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 where 1fr 1fr would 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: sticky does 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 with margin-top: auto in a flex column: auto-margin handles the short case, sticky the long one. ⚠️ And the two fight a negative margin-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: auto must also come after any margin shorthand.

  • ⚠️ Never mix getBoundingClientRect() with clientHeight/offsetTop on anything inside a transformed container. Rects are in scaled coordinates, layout properties are not — rect.top + el.clientHeight produced 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 a transform make this a routine hazard: it has now caused four false alarms.)

  • ⚠️ An inline style.display set by the page's own JS beats your rule. Service Status does table.style.display = 'table' every time it renders incidents, so the card‑feed conversion needed display: block !important — without it the feed works until the first refresh and then silently reverts. Grep a module for style.display before 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.js branch. 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 after mobile.css. Rules aimed at .tox-* need !important or they silently lose. The tell is a fix that looks like it half worked: hiding the upsell badge (a display rule that happened not to collide) succeeded while flex-wrap: nowrap on 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 fullscreen plugin 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. A position: fixed; inset: 0 overlay with your own always‑visible Close, plus a history.pushState so the device back button exits, is a dozen lines and cannot trap anyone.

  • Harvest a translated word, don't write one. common.back was needed for a shortened mobile button. Rather than an EN‑only key that falls back silently in 23 locales, each locale's common.php took its own existing translation of the same word from change-management.php. Zero invention, zero fallback, and the generic word ends up in common where the next module finds it. Worth checking for before assuming a new string is unavoidable.

  • ⚠️ Grep a module for localStorage before 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–29.

  • JS: assets/js/mobile.js — one matchMedia‑gated IIFE.

  • Related: Mobile‑Friendly (strategy & iOS lessons), Mobile: Tickets (what was built), Mobile: Calendar, Theming & Dark Mode.

⚠️ **GitHub.com Fallback** ⚠️