-
-
Notifications
You must be signed in to change notification settings - Fork 29
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.
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.
.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.
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.
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.
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>. "Linkmobile.csslast" 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.
.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.
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.
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.
.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.
Β§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.
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.
Β§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.
.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.
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.autolet a 273px filename claim the whole line and push its own status badge to the next row;0let 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.
paddingfrom a rule applied whilegrid-template-columnsfrom 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-widthat(0,3,0)beat the override no matter what order the files loaded in, exactly asinbox.css:2653did to LAYER 3's antiβzoom rule andinbox.css:2549did 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-headerfrom a screenshot; the identical fault sat on.page-headeracross nine RFP pages and turned up on the next screenshot, taken for something else.justify-content: space-betweenon a flex row containing a heading is the signature β grep for the shape once you have found it.
| 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.
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 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 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.cssis@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 nodesmobile.jsinjects..cv-act-icon { display: none }in view.php,display: inlineinside the media block.
The same audit caught three more leaks in the same change, all reverted to mobile-only:
-
titleattributes on the five buttons β needed only when the label is hidden, so they are now stamped bymobile.jsbehindmq.matchesand 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
#f8f8f8on 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.jsputs 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 toundo redo β¦and buried the one control that rescues a 300px editor. Branched onmatchMedia, so above 768px the desktop config is byte-identical.
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.
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.
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.
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.
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 autoon 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.
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.
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:
- the strip layout itself (fixed at the time, with
html.for specificity); - 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;
- the Overview scroller β
display: noneon the list applied whileoverflow-y: autoon 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 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.
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.
"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.
.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
:hoverfor rules that changeopacity,visibilityordisplayrather than just colour. Those are controls that do not exist on a phone.
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.
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 1frthat 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.
.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: 0is 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.
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)), notJSON.stringify(x).
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.
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.
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.
"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: 768pxstops 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.initto 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.
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.
- 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-labelon 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 β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π REST API: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Domains
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ π’ One board across every company
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)
- What this is
-
π Bugs resolved
- β³ πΌοΈ Logo and courses broke on Apache with PHP-FPM
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96