Skip to content

Mobile Friendly Contracts

Ed Mozley edited this page Aug 31, 2026 · 6 revisions

Mobile‑Friendly: Contracts

The fourteenth module brought along, and the largest single round of the rollout: 22 pages β€” the contracts list, a contract's detail and edit views, suppliers, contacts, settings, the in‑app guide, a printable equipment report, and the thirteen‑page RFP Builder that hangs off the same navigation. Shipped in #1362-#1365, polished in #1366-#1374 #1375-#1382 and #1383-#1388. mobile.css v103 / mobile.js v37, LAYER 28 (28a–28v), and 76 pages now link the shared layer.

Read Mobile‑Friendly first for the strategy and the one hard rule, and Techniques & Tricks for the code‑level catalogue this round draws on and adds to.


Where it started

docScrollW at a 360px viewport 1262 on twenty of the twenty‑two pages
Pages already contained one β€” rfp-builder/preview.php, at 345
.rfp-main usable width 100px
Widest table audit.php, 1004px of content
Tallest crushed cell extracted.php, 129px wide Γ— 358px tall

The 1262 is the interesting number, because it was the same on all twenty pages. A figure that identical is never the module β€” it is the shared header row, exactly as it was for Watchtower, Problem Management and Change Management. Linking mobile.css took all twenty to 360 with no module CSS written at all.

What opting in did not fix is the rest of this page.


The shell is one shape written twice

.contracts-layout and .rfp-layout are the same declaration β€” display: flex, height: calc(100vh - 48px), a 260px sidebar, a flex: 1 main β€” and both fill that sidebar with the same .sidebar-section / .sidebar-stat / .sidebar-link / .sidebar-add-btn classes. Every rule in 28a is written against both selectors, so the RFP Builder inherited the entire shell for the cost of a comma.

Half of Change Management came free the same way, but by coincidence of naming. This is the first round where it was free by design, and it is worth checking for at the start of a module rather than discovering halfway through.

The sidebar becomes the compact header strip Problem Management settled on: the search and New buttons share the top line, the figures become a horizontal chip row, the quick links become another. It uses wrapping flex rather than PM's two‑column grid, because the two sidebars do not hold the same things β€” contracts has a search button and an add button, the RFP Builder has only the add button. flex: 1 1 140px lets the pair share a row where there are two and lets the single one take the whole width where there is one, with no page‑specific rule and no :nth-child to go stale.

The sections are keyed on what they contain (:has(.sidebar-stat), :has(.sidebar-links)) rather than on position, for the same reason: the two sidebars hold the same sections in a different order and a different count.

⚠️ The quick links are not hidden, and the first instinct was wrong

On the contracts sidebar the four links genuinely duplicate .header-nav β€” Suppliers, Contacts, RFP Builder, Settings are all in the views drawer already. Hiding the row would have saved ~180px.

But the RFP Builder's two links are "Back to contracts" and its own help page, and the header's Help button goes to contracts/help.php β€” a different document. Hiding the row would have left the RFP Builder with no link to its guide at all. A shared rule has to be right for every page it reaches.


πŸ”΄ The 16px sidebar that only opens on hover

Contracts has a per‑analyst left panel preference. Set to hover, contracts/includes/header.php puts .contracts-sidebar-hover on <html> server‑side and collapses the sidebar to a 16px rail that expands on :hover.

On a phone there is no hover. The search box, the five figures, the quick links and the New contract button were all inside a 16px strip that could not be opened at all.

This is iOS lesson #4 β€” a desktop‑only mode leaking onto mobile β€” with the state on the server rather than in localStorage. There is no function to wrap and no class to strip in JS: it is on the page before any script runs. CSS is the only lever.

…and it exposed a gap in the load‑order rule

The rule that collapses the rail is (0,3,0) and lives in a <style> block that header.php emits inside <body> β€” that is, after mobile.css in document order. Β§9 says to link mobile.css after the page's own <style>, and every page here does. It was still not enough.

πŸ”‘ A shared include can put a stylesheet after the page's <head>. "Link mobile.css last" is a rule about the page, and a page is not the only thing that emits CSS. Where an include does, win on specificity instead β€” html. prefixed here to make it (0,3,1), no !important.

⭐ And it explained why two identical shells measured differently

.contracts-sidebar-hover scopes to .contracts-layout .contracts-sidebar only β€” it never matched .rfp-sidebar. So the preference was accidentally shielding the contracts pages from the 260px squeeze while the RFP Builder took it at full force and left its content 100px wide.

Two pages built from the same shell measuring differently was the thread worth pulling, and it led to both the bug and the reason it had been invisible.


πŸ”΄πŸ”΄ Nine modal shells, and LAYER 3 can see one of them

The wiki records this trap twice already: Problem Management had a modal class of its own, and so did Change Management. Contracts has nine, which changes the answer.

.cv-modal   .modal-card   .modal-edit-shell   .edit-modal   .stream-modal
.modal-shell   .modal-history-shell   .batch-modal
(+ .modal-content β€” the one LAYER 3 actually covers)

This is not cosmetic. LAYER 3 carries the anti‑zoom rule, and iOS lesson #1 is that a field under 16px makes Safari zoom on focus β†’ the sheet spills β†’ Safari reflows to a desktop‑width layout β†’ max-width: 768px stops matching at all. One unreached modal does not degrade gracefully; it switches the entire mobile layer off. Nine unreached shells is nine ways to lose the whole thing, on a module whose forms are mostly dates.

Key on the overlay, not the shell

Enumerating nine class names is what 15e already called "losing to the codebase". Every one of the nine is the child of an overlay, and there are only three overlays:

[data-mobile-module="contracts"] .modal-backdrop  > *,
[data-mobile-module="contracts"] .modal-overlay   > *,
[data-mobile-module="contracts"] .cv-modal-overlay > * { /* full-screen sheet */ }

Three names instead of nine, and it covers the tenth shell nobody has written yet.

⚠️ …and it must be scoped, which is what the module marker is for

.modal-overlay is also the class name used by Change Management, Tasks, the ticket inbox, the login page and the reporting logs; .modal-backdrop is system/integrations'. A bare .modal-overlay > * would have silently restyled the modals of five other modules, three of them already shipped and device‑tested.

That is Β§15 precisely, and it is the fault that no measurement of the page you are working on can find. Every page in this module carries data-mobile-module="contracts" for this reason, and the generic names β€” .section-card, which Change Management's settings page also uses β€” are scoped through it or through .contracts-main / .rfp-main / .page-wrap.


Six card feeds, and three that stayed tables

Β§11 taken nine times.

Feeds β€” the contracts list, contacts, suppliers, the RFP list, RFP documents, RFP extracted requirements. Each is read one row at a time, the columns are ours rather than user‑chosen, and one of them is outright prose: the requirement text on extracted measured 129px wide and 358px tall before this round, which is a sentence rendered as a column of single words. It is now a 300Γ—105 paragraph.

Scrollers β€” .heatmap (coverage), .winners-table (compare), .ai-log-table (view) and .audit-table (audit). The first two put a supplier per column; the last two are 8 and 9 columns with four numeric ones (tokens in, tokens out, cached, time) that you read by scanning down. Settings keeps its sideways scroll too, per Ed's #1004 rule that every table on every settings screen behaves the same way.

πŸ”΄ A <thead> in the markup is not the column list

compare.php ships two static <th> β€” Category and Winner β€” so a grep of the file said "four columns, two of them names, a comfortable card feed", and one was built. The rendered row is Category | 5.00 | 5.00 | Tied: the middle columns are emitted one per supplier by the page's own JS, so the real column count is not in the source and is not fixed.

That puts it squarely in Β§11's right‑hand column β€” the columns are user‑chosen or open‑ended β€” and the feed rendered two unlabelled 5.00s stacked under a category, which is the least readable form of a comparison there is.

⭐ It was caught by dumping the first rendered row's cells instead of reading the <thead>. A table whose columns come from data cannot be counted in the editor; §11's questions have to be asked of the page.

The harvested headings, promoted

Β§21 was written for Forms against #formsTable. It is the second module's turn and the rule was identical, so td[data-mobile-label]::before is now general rather than copied β€” mobile.js only stamps the attribute on tables named in its FEEDS list, so a table it has never been pointed at cannot pick it up by accident.

This round is the argument for Β§21 existing. Under Β§11 alone a seven‑column contracts list was a marginal call and an eight‑column RFP list was not a feed at all; in both cases the only objection was that some columns cannot speak for themselves. The commonest case here is two names in a row, which no column count reveals: the contracts list puts Supplier beside Owner β€” a company and a person, both plain text β€” and the suppliers list puts Legal name beside Trading name, two names for the same organisation. Unlabelled, the second of each pair is unreadable.


⚠️ The nav that a CSS‑only opt‑in hides

.header-nav is parked at transform: translateX(100%); visibility: hidden, and only body.mobile-views-open brings it back β€” a class only mobile.js sets. A page that links mobile.css and not mobile.js therefore has its module navigation permanently hidden with no way to open it.

Service Status and Watchtower are described as having "no mobile.js branch", which means no new code in the file β€” both still link it, as does every opted‑in page. Measured before the fix on contracts/index.php: visibility: hidden, shifted 266px off‑screen, and no hamburger element in the DOM at all.

πŸ”‘ "This module needs no JavaScript" is a statement about the contents of mobile.js, never about whether to load it. All twenty app‑shell pages link both files.


Two pages that deliberately do not link mobile.css

equipment-report.php and rfp-builder/preview.php carry their own @media (max-width: 768px) block instead β€” the documented exception under Where mobile CSS actually lives, the same call the landing page makes. Both are plain scrolling documents with no app shell, and LAYER 2's unconditional body { display:flex; height:100dvh } would clip them.

The equipment report's block also stays out of contractReportCss(), where the rest of its rules live, because that function is shared with api/contracts/email_equipment_report.php β€” the same stylesheet is inlined into an email. Email clients treat @media wildly differently and none of that is testable from here, so the screen gets its own rules on the screen's own page.

Its six‑column table scrolls rather than becoming a feed: three of the six columns are bare codes (serial, asset tag, reference), which is exactly what Β§21 exists for, and Β§21 needs mobile.js to harvest the headings β€” which this page has no other reason to load. A feed would have been six unlabelled values.


Lessons this round added to Techniques

  • Β§23 β€” a card's identifier needs flex: 1 1 60%, and both obvious values are wrong. auto let a 273px filename claim the whole line and push its own status badge to the next row; 0 let the badge and two counts crowd onto the header line and squeeze a name into 65px wide by 60px tall. The tell was one page in six laying out differently under identical rules β€” the difference was the data, not the CSS.
  • Β§24 β€” the load‑order gap above.
  • A rule that half‑works. padding from a rule applied while grid-template-columns from the same rule did not. A rule that does not match changes nothing (Β§9); a rule that is obeyed and then undone by its children looks completely different. Here .detail-group.full-width { grid-column: span 2 } made a one‑column grid create an implicit second column β€” 15e's trap, met again β€” and the fix keys on the grid (> *) rather than on the five different names those spanning children have across three files.
  • Third time for inbox.css. .form-grid .form-group.full-width at (0,3,0) beat the override no matter what order the files loaded in, exactly as inbox.css:2653 did to LAYER 3's anti‑zoom rule and inbox.css:2549 did to 16i's modal inset. Grep a shared stylesheet for the class you are overriding β€” a rule that loses on specificity looks identical to one that was never written.
  • The overriding rule can be your own. A later duplicate in the same layer quietly undid a minmax(0, 1fr).
  • A shape, not a class. The crushed page header was fixed on .contract-card-header from a screenshot; the identical fault sat on .page-header across nine RFP pages and turned up on the next screenshot, taken for something else. justify-content: space-between on a flex row containing a heading is the signature β€” grep for the shape once you have found it.

What the checks did and did not catch

Fault Contained? Uncontained count Found by
Header row 1262px wide ❌ 0 docScrollW
.rfp-main at 100px βœ… 0 pane width vs parent
Requirement cell 129Γ—358 βœ… 0 cell geometry (Β§19)
Detail header crushed to ~130px βœ… 0 screenshot only
.page-header on nine pages βœ… 0 screenshot only
Nav drawer unreachable βœ… 0 driving the hamburger
.winners-table wrongly a feed βœ… 0 dumping a rendered row
Modals invisible to LAYER 3 βœ… 0 grepping class names

Seven of the eight were invisible to every containment number, and two of those needed a picture. That is Β§11's "overflow gets reported; crushing does not" holding for the fourth module running.

Desktop is provably untouched: at 1100px, every page measures identically to the pristine stylesheet β€” docScrollW=1258, .rfp-sidebar 260px, .contracts-sidebar 16px, .contract-card-header still a row, .contract-details still 474px 474px.


Polish pass 1 (#1366-#1374)

Ed's first review of the shipped round. Nine items, of which four were bugs rather than layout:

# What Root cause
1 The quick-links row duplicated the drawer Judgement call reversed β€” see below
2 No padding around Search / New contract The hover rail's padding: 0 at (0,3,0) beat the strip's padding: 12px
3 Totals as dashboard cards, not chips The one figure people open the module for was behind a swipe
4 Actions as a bottom icon bar LAYER 5 / 17k's pattern, third user
5 Terms bullets outside the panel inbox.css's global * { padding: 0 } strips a <ul>'s indent
6 Save/Cancel not at the bottom sticky cannot escape its parent; plus a -30px bleed against a 12px parent
7 Empty document-removal confirmation showConfirm takes an object; it was passed a string
8 No full-screen button on the terms editor Plugin absent
9 Dark-mode switches invisible when off Track painted with a surface token

⚠️ The quick links: I kept them, and that was wrong

The shipped round deliberately kept the row, reasoning that hiding it would cost the RFP Builder the only link to its own guide. Ed's answer was that the four contracts links are in the hamburger and should simply go. The right fix was there all along and more precise than either position: hide the contracts row entirely and, on the RFP Builder, hide only the one link whose destination is already in the drawer (../), keeping help.php. Keying on the href states the actual test β€” is this already in the drawer β€” instead of guessing from the row.

πŸ”΄πŸ”΄ The one that matters most: I altered the desktop

The bottom icon bar needs icon spans in the markup. I added them and styled them in view.php's own stylesheet, which put emoji beside every button label on the desktop contract screen. Ed spotted it immediately:

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

That is the rollout's one hard rule, and the failure is worth recording precisely because the rule was being followed everywhere else in the same change.

πŸ”‘ mobile.css is @media-only by design, so it can only ever REVEAL β€” it cannot set a desktop default. Anything a mobile layer shows must therefore be hidden at source, in the page's own stylesheet. That is the wiki's existing "injected chrome must be hidden off-mobile" corollary, and it applies to markup the page ships as much as to nodes mobile.js injects. .cv-act-icon { display: none } in view.php, display: inline inside the media block.

The same audit caught three more leaks in the same change, all reverted to mobile-only:

  • title attributes on the five buttons β€” needed only when the label is hidden, so they are now stamped by mobile.js behind mq.matches and removed again when the viewport leaves mobile. Zero invented strings: the text is harvested from the button's own .cv-act-label, the same trick Β§21 uses for column headings.
  • A border on two secondary buttons, which Ed had not asked for.
  • The list-indent fix, moved from view.php into LAYER 28l. The cause is width-independent, but the desktop panel's 30px of padding absorbs the markers, so there is nothing to fix there.

Two changes remain deliberately global, and both were flagged rather than slipped in:

  • The switch track (#9). It was #f8f8f8 on a white panel in light mode too, so it was near-invisible at every width in both themes β€” a reported bug, not mobile work. Gating it would have left the desktop carrying the fault Ed had just asked to have fixed.
  • The full-screen button (#8), which he asked for. It is appended to the end of the desktop toolbar, exactly where lms-editor.js puts it, so a desktop user gains one button and nothing moves. On a phone it goes first, because at 360px TinyMCE's own overflow collapsed the bar to undo redo … and buried the one control that rescues a 300px editor. Branched on matchMedia, so above 768px the desktop config is byte-identical.

πŸ”΄ A backtick in a comment is still a backtick

The icon markup went into renderContract()'s template literal along with an HTML comment explaining it β€” and that comment contained backticks. It closed the template string and took the whole <script> block with it: renderContract and loadContract both came out undefined and the page sat on "Loading contract...". Caught by asserting typeof renderContract rather than by looking at the page, which showed only a plausible-looking loading state.

The empty confirmation box (#7), and why it could not fail loudly

window.showConfirm takes an options object. documents.js passed the message as a bare string, so opts.message was undefined and the dialogue rendered a title, an OK and a Cancel with no question in it. The browser confirm() fallback took the same string and read correctly, which is why it only ever showed where the app dialogue was present.

πŸ”‘ A helper whose argument is a string in one branch and an object in the other cannot fail loudly. Both are truthy, both resolve, and the only symptom is a missing sentence. Proven with a negative control: calling it the old way still renders "Confirm / Cancel / OK" and an empty body.

This affected every record type that can hold documents, not just contracts.


Polish pass 2 (#1375-#1382)

Five items. This round changed nothing on the desktop β€” the only edits to any page file are three <body> marker attributes; everything else is inside the @media block or in mobile.js behind mq.matches. Verified with the Β§25 diff audit and a 1100px control.

A record gets the whole screen (28m)

Measured on the detail page at 360Γ—740: the strip was 279px, the contract 411px. So 38% of the screen went on a New contract button for a task you are not doing and five figures about a list you have already left.

⭐ The strip earns its place on a LIST page and nowhere else. It is orientation, and orientation is what you want before you have chosen a record. Once you are in one, the record is the screen β€” the same reasoning the master‑detail stack is built on. Back is the first control in the bottom bar, so nothing is lost.

Overview and directory on suppliers and contacts (28q)

Two views over one page, driven by a single data-contracts-view attribute on <body>:

Overview the headline total and a breakdown by type/status β€” and nothing else. The default, because landing somewhere should tell you where you are
Directory the strip goes, the list gets the whole screen, and a search bar pins to the bottom filtering that page's records only

The first version left the list card visible below the figures in Overview, and Ed's answer settled the design: "please make the overview for suppliers and contacts full screen β€” at the moment there is a section at the bottom where you can see the listing of them." He was right, and it is what makes the switch mean something β€” Overview‑plus‑the‑list and the‑list are not two views, they are one view and a taller version of it. Figures, or records.

πŸ”‘ And the strip then has to become the scroller, because it is the whole page. 28a gives it flex: 0 0 auto on the assumption that a pane below it does the scrolling; with that pane hidden, a breakdown with a dozen types runs off the bottom of a page that cannot scroll. Every opted‑in page must declare its own scroller β€” the Calendar round's rule, met again in a view rather than on a page.

⭐ Zero new translation keys, and the labels came out better for it. The obvious pair is "Dashboard / Directory" β€” neither exists in the locale files, and both would have meant a fan‑out to 24 languages. What does exist is contracts.list.overview and the nav labels, so the switch reads Overview | Suppliers on one page and Overview | Contacts on the other β€” naming the thing you are about to browse rather than describing the layout. Same discipline as the Calendar round.

Injected, not shipped hidden. Β§25 says anything a mobile layer reveals must be hidden at source β€” but that only pays for itself when the desktop needs the element. Here it never does, so mobile.js builds the switch and the bar and nothing is added to either page. The corollary still applies: both are created with an inline display: none, revealed by a syncChrome() that also removes the body attribute above 768px β€” otherwise a desktop resize would strand the page in a view whose CSS no longer exists, the strip hidden with no switch left to bring it back.

πŸ”΄ A rule that is right on one page and wrong on another is usually a DOM difference

The stat‑card grid built for the contracts dashboard produced a tall empty box beside a column of headings on suppliers. No amount of tuning the rule would have fixed it: suppliers and contacts do not hold a flat list of figures, they hold #overviewBreakdown β€” a container of <h4> headings each followed by its own rows β€” plus one loose total under a fourth class name (.sidebar-total, not .sidebar-stat).

display: contents is the fix and it is the right one: it removes the wrapper from the layout without removing it from the DOM, so the headings and figures become direct participants in the grid and lay out exactly as the contracts ones do. A nested second grid would have given the breakdown its own column track and left the two pages looking different again.

πŸ”‘ When a shared rule produces a shape on one page that tuning cannot fix, compare the markup before adjusting the rule.

πŸ”΄πŸ”΄ The mode rule that has now won three fights I did not know I was in

contracts/includes/header.php emits the collapsing hover rail's CSS at html.contracts-sidebar-hover .contracts-layout .contracts-sidebar β€” (0,3,1), and inside <body>, so it also wins every tie on document order (Β§24). It has now beaten three separate rules written against the same element:

  1. the strip layout itself (fixed at the time, with html. for specificity);
  2. the strip's padding β€” my neutralisation undid five properties and not that one, so Ed's "add some padding" was a rule that had never applied;
  3. the Overview scroller β€” display: none on the list applied while overflow-y: auto on the strip silently did not, so a long overview ran off the bottom with no way to reach it.

πŸ”‘ Once a page carries a high‑specificity mode rule, every later rule touching that element is competing with it. Check the computed value, not the rule you wrote β€” all three of these looked correct in the stylesheet.

The search sheet (28o) β€” and why it was not scoped

"the screen gets a bit chaotic especially in dark mode as it's a sea of yellow and black without clear borders"

.search-modal was a 500px box at top: 100px with the page showing round three of its edges, and contracts paints its header amber. Two separate faults: it was not a sheet, and its results separated themselves only by a hover tint, which a touch screen never shows.

The sheet is fixed for all four modules that share the class β€” Tickets, Problem Management and Change Management too β€” and the precedent is fifteen lines up in the same file, where the anti‑zoom fix for the same class records: "All three were zooming for the same reason, so all three are fixed here rather than scoping the fix to the one that got reported." None of the four is usable as a floating box on a phone, and LAYER 3 already makes every other modal in the product a sheet; this class was simply never taught the rule. The colours are scoped to contracts, because only this one paints itself amber.

Promoting a modal is only half the job (28n)

The asset picker's sheet went full height and its list did not, because the page caps .asset-pick-results at max-height: 320px β€” right for a 480px centred dialogue, barely half a phone. Two scroll containers, one inside the other, and the inner one was the shorter.

πŸ”‘ Whatever the page put INSIDE a modal still has the height it was given for a small box. Worth checking on every sheet with a scrolling list in it.

πŸ”΄πŸ”΄ The worst crush in the module, and a second fault hiding behind it (28r, #1380-#1381)

"when viewing consolidated requirements in the rfp section we have pills e.g. 'requirement' then 'critical'. please can these pills have their own row and then the text that is currently squished into the right hand column can use the full width below them"

Measured before the fix:

.req-row-text  =  104px wide Γ— 801px TALL

.req-row-top is a single nowrap flex row holding a checkbox, two pills, the requirement and three action buttons. Everything except the text sizes to its content, so the one thing on the row anybody is reading got what was left β€” a sentence set two or three words to a line for eight hundred pixels. Worse than extracted.php's 129Γ—358, which was the previous record for this module.

Β§11 settles it without argument β€” the column holds prose β€” and the pills are metadata about the requirement, so they get the same treatment as the chips on the extracted feed: a short labelled row above the thing they describe, prose full width beneath. Now 304Γ—267.

⚠️ flex-wrap: wrap on the row plus flex: 1 1 100% on the text, not display: block on the text β€” a flex item's display is blockified anyway, and it needs a flex-basis of 100% to be pushed onto a line of its own. LAYER 26 wrote that up and 28c hit it again.

⭐ And the fault nobody reported, because there was nothing to see

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

Edit, Split and Delete were revealed by hover β€” and a touch screen never hovers. On a phone the three controls existed, took their share of the row (squeezing the text further still), and were permanently invisible: tappable by accident, never on purpose.

πŸ”‘ A hover-revealed control is not a control on a phone. This is the same class of fault as the search results that separated themselves only by a hover tint (28o) β€” two instances in one module, found a day apart, neither of them reported as "the buttons are missing" because an invisible control reads as an absent feature rather than a bug.

πŸ“Œ Worth a sweep on every module already done: grep :hover for rules that change opacity, visibility or display rather than just colour. Those are controls that do not exist on a phone.


The settings audit (28p)

Ed asked for contracts' settings to be checked against the rules and against the modules already done. The tables were already right: every one keeps its header row and scrolls sideways, which is 15e and his own #1004 rule, and the scroll ratio (2.4Γ—) sits between service‑status (2.2Γ—) and calendar (1.7Γ—).

What was not consistent was the top of the page. Measured across all fourteen settings screens, .container starts at y=50 on eleven and y=80 on three β€” asset‑management, service‑status and contracts β€” each carrying a margin: 30px 0 that 15e sets padding against but never overrode. A fifth of the space above the fold, on some screens and not others. Fixed in the shared settings block so all three come right together and the next module cannot reintroduce it.


⭐ "Squished in randomly" is a diagnosis, not a complaint (28s, #1382)

The edit-requirement sheet put Type, Priority and Category in a 1fr 1fr 1fr grid. At 360px the three tracks measured:

133px    101px    74px

Not thirds β€” and Ed's word for it was "squished in randomly", which is precisely what a reader sees when column widths are driven by their own contents rather than by the share they were asked for.

A 1fr track is minmax(auto, 1fr), and that auto floor is the track's min-content width. So each column was sized by its own longest <option>, and Category β€” whose options happen to be shortest β€” ended up a 74px dropdown that cannot show a category name. Β§14's min-width: auto in its grid form, and the third time this round it has been the answer.

πŸ”‘ Uneven tracks from an even declaration is the signature. 1fr 1fr 1fr that measures 133/101/74 is not a tight layout β€” it is a layout whose widths are coming from somewhere else. minmax(0, 1fr) if you want them even; one per row if the screen is a phone.

One field per row, matching what .contract-details (28f) and 15e's inline settings grids already do. The desktop dialogue is untouched β€” at 1100px the same grid measures 192/192/192.


Polish pass 3 (#1383-#1388)

πŸ”΄πŸ”΄ Eleven pixels

.cat-header on the generated-document screen measured:

.cat-info        11px wide Γ— 480px tall
.cat-header h2   11px Γ— 88px      ("Identity and access management")
.cat-desc        11px Γ— 264px
.cat-meta        11px Γ— 118px
.cat-actions     261px

An eleven-pixel column. The section title, its description, the requirement count, the version and the generated timestamp all rendering at roughly one character per line down a 480px ribbon, while three buttons held 261px of a 328px card. It is the worst measurement found anywhere in this module, and the fourth instance of one mechanism:

.cat-info    { flex: 1; min-width: 0; }   /* I will take whatever is left */
.cat-actions { flex-shrink: 0; }          /* there is nothing left */

⭐ Note the inversion. min-width: 0 is usually the fix β€” Β§14 exists because a flex item that cannot shrink pins its container open. Here it is half the fault, because the item beside it is the one that will not give way. Neither declaration is wrong alone; together they are 11px.

Same shape, same round: .req-row-text at 104Γ—801 (28r), .framing-card-header h3 at 83px against 261px of buttons, and .contract-card-header back in pass 1. A nowrap flex row holding prose and a button group is the signature β€” it has now appeared four times in one module under four different class names.

πŸ”΄ The Delete button that had never worked, on any device

onclick="deleteDoc(3, " risk-department-secure-data-room.docx")"=""

JSON.stringify() wraps the filename in double quotes, and the attribute is itself delimited by double quotes β€” so the parser ended the attribute at the first inner quote and the filename became stray attributes. The handler was the truncated string deleteDoc(3, and every press raised SyntaxError: Unexpected end of input.

Silent, because an inline handler that fails to parse throws into nothing β€” no dialogue, no error, no clue. It presents exactly as "the button does nothing", which is why it survived on the desktop too since the page was written.

πŸ”‘ A value going into an HTML attribute needs HTML escaping even when it is already valid JavaScript. The attribute is parsed first. escapeHtml(JSON.stringify(x)), not JSON.stringify(x).

⚠️ Its neighbour was a different fault with the same symptom: "an icon that looks like refresh but it doesn't do anything". That one fired correctly every time β€” it just said nothing, and re-running text extraction on a document that already has text produces an identical-looking row. A silent success and a silent failure are indistinguishable from the outside. It now confirms with the character count, using documents.chars ('{n} chars') which already existed β€” no new key β€” and as a toast rather than setStatus(), because #uploadStatus sits mid-page beside the upload box and measured height: 0.

A sheet is not a size

The procurement-context note was a fixed six rows β€” 166px inside a 626px sheet. Promoting a modal to full screen (28n) does not resize what the page put inside it, and this is the second instance in two rounds. Scoped by ID (#contextModal), following LAYER 3's #noteModal, which records why: a fill-the-sheet rule is right for a modal holding one field and wrong for every multi-field form.

πŸ”΄ A backtick in a comment is still a backtick β€” second time

Documenting the Delete fix, the explanation went in as an HTML comment inside the backtick template literal that builds the row β€” and it contained backticks of its own. It closed the string and took the whole <script> with it: every function came out undefined and the table sat on "Loading...".

The identical mistake was made in view.php two rounds earlier and written up in that very file. Writing a lesson down does not stop you repeating it; the thing that caught it both times was asserting typeof renderDocs rather than looking at the page, which showed only a plausible loading state.


πŸ”΄πŸ”΄ The big one, in six of eleven editors (#1388)

"when you edit a section e.g. introduction can you make it so when you type in the tinymce editor to start making changes it doesn't zoom in β€” this is safari I think"

Ed is right, and it is iOS lesson #1 β€” the worst failure mode in the whole rollout, because it does not merely look wrong:

focus a field under 16px β†’ iOS zooms β†’ the sheet spills wide β†’ Safari re-lays the page out at desktop width β†’ max-width: 768px stops matching β†’ the entire mobile layer switches off until you reload.

CSS cannot reach inside a TinyMCE iframe, so the fix has to be in the editor's own content_style, keyed on pointer: coarse rather than a width query β€” a width query is unreliable inside an iframe, and coarse means the desktop is untouched by construction.

content_style: '… body { font-size: 14px; } @media (pointer: coarse) { body { font-size: 16px; } }'

The audit is the point. Grepping every tinymce.init in the product found eleven editors, six of them unguarded β€” and three of those six are in modules that are already shipped as mobile-friendly:

Editor
contracts/rfp-builder/document.php the one reported
assets/js/change-management.js shipped mobile module
assets/js/tasks.js shipped mobile module
tickets/settings/index.php (Γ—2) shipped mobile module
assets/js/lms-editor.js not yet rolled out
system/preferences/index.php not yet rolled out

All six fixed. This is the same shape as the .search-modal anti-zoom fix in #1193 β€” "all three were zooming for the same reason, so all three are fixed here rather than scoping the fix to the one that got reported" β€” and there is no desktop risk to weigh, because pointer: coarse cannot match a mouse.

πŸ“Œ Add tinymce.init to the pre-flight for every future module, alongside the @media-in-a-module-stylesheet check. An editor is exactly the kind of thing that gets added to a page long after its mobile round shipped, and this failure is silent right up until somebody taps into one.

How it was verified without a touch device

Headless Chrome reports pointer: fine, so the computed size cannot be read as 16px β€” measuring it would prove nothing either way. What can be proved is that the rule reached the iframe at all, which is the part that was actually missing:

const idoc = document.querySelector('#framingEditModal iframe').contentDocument;
// walk idoc.styleSheets for a rule containing "pointer:coarse"

β†’ @media (pointer: coarse) { body { font-size: 16px; } } present inside the editor iframe, with body computing to 14px in headless β€” which is the desktop value, and therefore also the proof that the desktop is unaffected.


Still owed

  • A real device pass. Everything here was verified in a headless 360px iframe driving the real authenticated pages, plus a 1100px desktop positive control. That finds overflow and crushing; it cannot judge a tap target or an iOS date picker, and Β§20 is emphatic that a desktop browser cannot show you the native control at all. A contracts module is mostly dates.
  • aria-label on the icon‑only row actions. The feeds end in a row of icon buttons with no accessible name β€” the same sweep the whole rollout still owes.
  • The RFP Builder's editor (document.php) uses TinyMCE. Change Management's round found that a rich‑text editor on a phone wants cards plus one editor built on demand; this round contained it and stopped there.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally