Skip to content

Mobile Friendly Knowledge

Ed Mozley edited this page Aug 9, 2026 · 4 revisions

Mobile: Knowledge

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

🧰 For the code‑level catalogue of the CSS/JS tricks used here, see Mobile: Techniques & Tricks.

Why Knowledge? It is the module with the strongest claim on a phone. Looking something up is what you do standing at somebody's desk, in a comms room, or in a car park β€” not sitting in front of the thing you use to write articles.

Shipped in three rounds: #1000 (the module), #1001 (buying back the screen above the article text) and #1002 (writing an article) β€” the last two both from Ed's device passes.


Scope

Page State
knowledge/ β€” list, article, editor Done deep β€” search into the sub‑bar, tags into a sheet, author‑HTML containment, editor reflow
knowledge/review/ β€” the review schedule Done β€” six‑column table becomes a card feed (17f)
knowledge/assistant/ β€” the gap finder Done β€” stacked header, scrolling tab strip (17g)
knowledge/settings/ Done β€” inherits LAYER 15e via data-mobile-page="settings"; it is forms only, no table to rescue
knowledge/help.php Done β€” inherits 16h (the app shell + a scroll container) from the Calendar round

Two of those five cost no new CSS. That is 15e and 16h earning their keep β€” and unlike the Calendar settings page, this time the inheritance really was the whole job, because it was checked rather than assumed.


Two things make this module different

1. There is no pane stack, and for a better reason than the Calendar's. .knowledge-main already shows exactly one of three views at a time β€” list, article, editor β€” toggled by showView() setting three display values. There is nothing to slide. What that state doesn't do is reach CSS, so mobile.js mirrors it onto body[data-kb-view] and the layer reacts to it.

2. The primary action is SEARCH, and search lived in the sidebar that had to go. Every previous module put its sidebar behind a button and left it there. Here that would be the wrong call: you open a knowledge base to find one article, so the search box is relocated into the sub‑bar where it is always visible. The tag filters, New article and the recycle bin go into the sheet. Search does not.

// Moved, not rebuilt β€” #articleSearch keeps its id and its inline
// onkeyup="debounceSearch()", so the module's own search needs no rewiring.
var box = sb.querySelector('.search-box');
bar.insertBefore(box, bar.firstChild);

The corollary is that the sub‑bar belongs to the list. On the article and editor views it is hidden entirely β€” searching or tag‑filtering from inside an article would silently rearrange a list you can't see, both views already carry their own Back/Cancel, and it is one less strip of chrome above the thing you came to read.


Reading an article: the widest content in the product

An article body is author‑written HTML from TinyMCE. It can hold a screenshot at its original pixel width, a seven‑column table of firewall rules, a registry path with no spaces in it, a page‑wide block of PowerShell. Every one of those is wider than 360px, and per Β§3 a wide thing loose in the layout doesn't merely overflow β€” it reflows the whole page to desktop width and switches the media query off.

pre was already an overflow-x: auto island. Everything else is the same treatment applied to the rest:

.article-content-body img,
.article-content-body video,
.article-content-body iframe { max-width: 100%; height: auto; }

.article-content-body table { display: block; overflow-x: auto; overscroll-behavior-x: contain; }
.article-content-body th,
.article-content-body td { white-space: nowrap; }

.article-content-body { overflow-wrap: anywhere; }   /* registry paths, URLs, hashes */

πŸ”‘ nowrap is doing the real work in that table rule, not the overflow. Without it a seven‑column table doesn't scroll β€” it crushes, wrapping every cell down to one word per line inside 360px. That is contained, so it passes the overflow measurement, and it is unreadable. display: block is separately required: overflow does nothing on a display: table box.

The trade is that a cell holding a full sentence becomes a long sideways swipe. For the tables people actually put in a runbook β€” rules, ports, versions, paths β€” that is the right way round, and it matches 15c and 15f.

The Share menu is an absolutely‑positioned dropdown pinned to a button that is now full width, so it becomes a bottom sheet β€” the same pure‑CSS move as the Calendar's quick‑view popup.

Round 2 (#1001) β€” buying back the screen above the text

Everything between the app bar and the first paragraph is overhead. Ed's device pass found three ways to reduce it, and one genuine bug.

Back joins the action row. It was taking a whole row to itself because "Back to list" is too long to sit beside Share, Edit and Archive. On mobile the label shortens to just Back and all four share one 42px row. The label comes from a new shared common.back β€” see the translation note below.

The meta block collapses, Gmail‑style. Four lines β€” author, created, modified, views β€” become one. Collapsed you see Modified and the tags; tapping reveals the rest.

.article-content-header .kb-meta-by,
.article-content-header .kb-meta-created,
.article-content-header .kb-meta-views { display: none; }
.article-content-header.kb-meta-open .kb-meta-by { display: block; }   /* …etc */

πŸ”‘ The whole meta row is the control, not a chevron. It is a much bigger tap target, and β€” the reason it was designed that way β€” its accessible name is the visible "Modified: …" text, so the toggle needs no aria-label and therefore no new string in 24 languages. role="button", tabindex, aria-expanded and a keyboard handler are set by mobile.js. The chevron is drawn with borders, not a β–Ύ glyph: at 11px the character rendered as a faint dot in the app's font stack, and a chevron that reads as a full stop is not an affordance.

⚠️ And the bug: the peek‑through strip. Article text was visible in the gap between the purple app bar and the pinned title β€” Β§5 all over again, in a module that had nothing to do with the audit sheet where it was first found.

knowledge.css:729 makes .article-content-header position: sticky; top: 0. A sticky element sticks to the top of its scroll container's content box β€” below its padding. .knowledge-main carries 12px of it, so the title pinned 12px down and paragraphs scrolled up through the transparent strip above it.

body[data-kb-view="detail"] .knowledge-main   { padding-top: 0; }   /* nothing to stick below */
body[data-kb-view="detail"] .article-detail-header { margin-top: 12px; }   /* the space, but scrollable */
.article-content-header { margin: -16px -16px 16px; padding: 14px 16px; }  /* cancels the card's 16px exactly */

Two details worth keeping: the space isn't deleted, it is moved onto something that scrolls away (the button row's margin), and the header's negative margin must match the card's padding exactly β€” the inherited -20px against a 16px‑padded card would lift the bar clear and open a strip of its own. body[data-kb-view] is what makes this scopeable to the article without touching the list.

πŸ”‘ The peek‑through is now the second sighting in two different modules. If a module has a position: sticky header, check what padding its scroll container carries before looking anywhere else.

Round 3 (#1002) β€” writing an article

The action row now reaches the bottom edge. .knowledge-main's 12px padding was holding Cancel / Save / Version a few pixels short of the screen and inset from both sides, so a footer read as a floating strip. Same fix as the detail view β€” zero the scroll container's padding for this view only and let the parts inside carry their own β€” plus env(safe-area-inset-bottom) so the buttons clear the iPhone home indicator.

body[data-kb-view="editor"] .knowledge-main { padding: 0; }
.editor-actions { margin: 0; padding: 12px 12px calc(12px + env(safe-area-inset-bottom)); }

Full-screen text editing. A button above the field lifts .editor-content out to position: fixed; inset: 0, with the article's title and a Close along the top. Before it, you were typing into a ~200px window sitting below six property fields.

πŸ”‘ Deliberately NOT TinyMCE's own mceFullScreen. The fullscreen plugin is loaded, so calling it would have been one line β€” but this init's toolbar has no fullscreen button and its only other exit is the View menu, so a phone user could get stuck in it. Ours carries its own always-visible Close bar and exits on the device back button. The .tox-tinymce { flex: 1; height: auto !important } trick that makes the iframe follow its container is the same one the desktop pop-out already relies on, so it was known to work here before it was written.

Two labels, no new strings: the open button reuses knowledge.editor.popout_title ("Toggle full-screen view", already translated for the desktop pop-out button that is hidden on mobile), and the bar shows the article's own title, read live from the Title field β€” once you are in full screen, repeating the button's label tells you nothing, whereas what you are editing is useful.

Reclaiming the editor's own furniture. Measured 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. The badge is hidden; the menubar scrolls sideways on one row instead of wrapping β€” every menu still reachable, which hiding it would not have been. Typing area went 469px β†’ 508px β†’ 547px of 720.

⚠️ TinyMCE injects its skin stylesheet at RUNTIME, so it always lands in <head> after mobile.css and wins every specificity tie on source order alone. The first attempt without !important looked like it half-worked β€” but the improvement was entirely the badge going; the menubar was still wrapping. When a rule aimed at a JS-mounted widget appears to partly work, measure which half.

No new English strings

common.back is the only string added β€” and it was harvested, not written: all 24 locales already translate the bare word "Back" in change-management.php, so each locale's common.php got its own existing wording. Zero invention, zero silent English fallback, and every future module gets a translated Back for free. The generic word also belongs in common rather than in a fifth module‑specific copy of it.

The only other markup change is four class names (.kb-meta-by, .kb-meta-created, .kb-meta-modified, .kb-meta-views) added to the renderer's meta spans. Nothing targets them on desktop; they exist so the collapse rule can name the line it hides instead of counting to it β€” the #937 lesson, applied by adding the missing class rather than reaching for :nth-child.


⚠️ The localStorage desktop mode, second sighting

This is the #762 tickets bug in a different module, and it is worth treating as a pattern to go looking for rather than a coincidence:

function applyEditorPopoutFromPref() {
    const prefersPopout = localStorage.getItem('knowledge_editor_popout') === '1';
    container.classList.toggle('editor-popout', prefersPopout);
}

.editor-popout turns the editor form into a row-reverse flex with a fixed 340px property panel. At 360px that panel is the screen and the editor itself gets nothing. And because the preference lives in localStorage, an analyst who ever turned it on at their desk carries it to their phone β€” the page looks broken for them and fine for everyone else.

Neutralised the same way as tickets: wrap at the source, leave the stored preference alone so desktop is unchanged, and keep a CSS backstop because the backstop is what saved the tickets one.

function stripEditorPopout() { if (mq.matches) container.classList.remove('editor-popout'); }
['applyEditorPopoutFromPref', 'toggleEditorPopout'].forEach(function (fn) { /* wrap */ });

The pop‑out toggle button is also hidden on mobile β€” the editor is already the whole screen, and leaving the button would let you re‑arm the preference.

πŸ”‘ When you bring a module along, grep it for localStorage before you start. Two of four modules have had a saved desktop mode that breaks the phone, and neither showed up until it was looked for.


The one justified edit outside mobile.css/js

TinyMCE renders into an iframe, so no rule in mobile.css reaches the text you are typing. It has to be 16px on a touch device or iOS zooms on focus and springs the reflow trap. That means content_style in knowledge.js β€” the same single justified edit inbox.js took in #766, and keyed on the pointer rather than a width so a narrow desktop window is unaffected:

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

How it works

Entry point

if (document.querySelector('.knowledge-container')) { initKnowledgeMobile(); return; }

Keyed on .knowledge-container so the module's other four pages take the shared shell and nothing else β€” the review page is .review-container, the assistant is .ka-page.

mobile.css layers

Layer Purpose
1–13 Tickets
14–15 Assets
16 Calendar
17 Knowledge β€” 17a shell + search relocation Β· 17b list Β· 17c article body containment Β· 17d editor + pop‑out neutralisation Β· 17e the tags sheet Β· 17f the review card feed Β· 17g the assistant Β· 17h the article-reading refinements (#1001) Β· 17i the editor footer + full-screen writing (#1002)

Opting a page in

    <link rel="stylesheet" href="../assets/css/mobile.css?v=36">   <!-- after the page's own <style> -->
    <script src="../assets/js/mobile.js?v=19"></script>            <!-- last -->

⚠️ mobile.css and mobile.js are shared. Bump the ?v= on every page that links them β€” fifteen now β€” not just the one you edited.


Challenges & solutions

Challenge Solution
Hiding the sidebar hides search, which is the whole point of the module on a phone. Relocate the real .search-box into the sub‑bar rather than into the sheet. Moved, not copied, so #articleSearch keeps its id and its inline handler.
Moving the search box out leaves its <h3>Search Articles</h3> section in the sheet with nothing under it. mobile.js marks that section .kb-dup as it lifts the box out, rather than the CSS guessing at a position.
The sheet said "Tags" twice β€” its own title and the panel's "Filter by Tags". Hide only the duplicate: .sidebar-section:has(#tagFilterList) h3.
A seven‑column table passed the overflow check while being unreadable. See above β€” white-space: nowrap is what turns crushing into scrolling.
The review card feed showed a bare 139 with nothing saying it meant days overdue β€” the word lived only in the column header the card feed drops, and there is no translated string to relabel it with. Hide the cell: td:has(.days-overdue). It says nothing the line above doesn't, because the review date immediately preceding it is already rendered red for exactly that case.
Once the headers go, a stack of bare values reads as unexplained strings. Hierarchy instead of labels: the title stays full‑strength, td + td is muted, and .review-date.overdue keeps its red on top β€” so the one cell that matters is the one that stands out. No CSS‑generated labels; they would be hardcoded English.
Both the editor pop‑out and the left‑panel hover preference are per‑analyst desktop modes that follow the user to the phone. Pop‑out is stripped on mobile. The hover mode needs nothing: relocating the sidebar into a sheet takes it out of .knowledge-container, so .sidebar-hover's 16px hot‑zone rules stop applying by construction.

Verification

Same harness as the Calendar round β€” the real authenticated pages driven in headless Chrome, asserted rather than eyeballed β€” with two additions:

  • A hostile‑author payload. After a real article renders, its body is replaced with the worst thing somebody can paste into TinyMCE: a 1400px image, a seven‑column table, a 120‑character registry path, a wide pre. Injected into the DOM, so no database write. Result: the image scales to 296px, the table scrolls at scrollWidth 636 > clientWidth 296, and docScrollW === innerWidth throughout.
  • The pop‑out preference deliberately ARMED. The harness sets knowledge_editor_popout = '1' in the iframe's localStorage before opening the editor, so the #762 defence is tested in the state that breaks it rather than the state that doesn't. A defence only exercised in the safe case is not a test.
  • Desktop positive control at 1400px asserting the inverse of every mobile claim, plus a regression sweep over Tickets, Assets and Calendar.

Two false alarms, both instructive

  • w.articleEditor was always undefined. knowledge.js declares it with let, which lives in the script's global lexical scope and is therefore not a property of window β€” unreadable from another window. The registry (w.tinymce.get('articleBody')) is a real global. This is the same let‑vs‑function distinction the Calendar branch relies on from the inside; from the outside it cuts the other way.
  • A "desktop sidebar is 16px, expected 280px" failure was the analyst's Left panel: hover preference doing exactly what it should. An assertion that hard‑codes one of two legitimate states reads a preference as a regression.

And one finding that was not this change

At a 1100px window several modules' headers overflow horizontally. It looked like a regression until a control measured contracts/, which does not link mobile.css at all and overflows to 1176px just the same β€” and mobile.css has no rules above 768px to begin with. It is the app's own desktop header needing more than 1100px on nav‑heavy modules. Recorded here so the next person doesn't re‑diagnose it: a desktop‑width failure on a page that never opted in is the control, not the bug.

Still owed: a real device pass with Ed.


Known rough edges / future polish

  • knowledge/assistant/ is hardcoded English in the markup β€” "Assistant", "Look for gaps", "To write", "Written", "Not needed" and its intro paragraph are literals in the PHP, not t() calls. A module bug rather than a mobile one, so it was flagged rather than folded into this change. Same class of thing as the Calendar's English‑only day names.
  • A table cell holding a full sentence becomes a long sideways swipe (the nowrap trade, above).
  • knowledge.js still renders article HTML raw rather than through safe-html.js β€” unrelated to mobile, but this work spent a lot of time looking at exactly that innerHTML, so it is worth writing down again.
  • The keyboard‑vs‑bottom‑input problem applies to the editor.
  • aria-label sweep across the injected chrome is still owed, module‑wide.

Reference

  • CSS: assets/css/mobile.css β€” LAYER 17 (17a–17i).
  • JS: assets/js/mobile.js β€” initKnowledgeMobile(). Plus the one edit outside it: assets/js/knowledge.js content_style.
  • Opt‑in wiring: knowledge/index.php, review/index.php, settings/index.php, assistant/index.php, help.php (currently mobile.css?v=36, mobile.js?v=19 β€” on all fifteen pages that link them).
  • Changelog: #1000 (round 1), #1001 (round 2) and #1002 (round 3).
  • Parent: Mobile‑Friendly Β· Siblings: Mobile: Tickets, Mobile: Assets, Mobile: Calendar Β· Techniques: Mobile: Techniques & Tricks Β· Module: Knowledge.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally