Repository navigation
Mobile Friendly LMS
The fifteenth module, and by some way the most polished β Ed put it through ten rounds of feedback after it shipped, and rather more than half of what is on this page came out of those rounds rather than out of the build. Six pages β the courses console, My courses, the course editor, the player (which routes to a SCORM frame or to the authoredβcourse player), the guide and settings. Shipped in #1392-#1395, then polished in #1396-#1410. mobile.css v117 / mobile.js v46 / lms.css v6 / lms.js v5 / lms-editor.js v4, LAYER 29.
Read MobileβFriendly first for the strategy and the one hard rule, and Techniques & Tricks for the catalogue this round draws on.
docScrollW=953 at 360px on five of the six pages β the same figure on each, which is the tell for the shared header rather than the module, exactly as it was for the four rounds before it. Opting in took them all to 360.
What opting in did not fix was one page carrying four tables, and two panels that a preβexisting media query simply deletes.
Grepping a module's own stylesheets for @media before writing anything is the Morning Checks lesson (#1269), and it paid again:
lms.css:680 @media (max-width: 900px)
.lms-editor-side, .lms-native-toc { display: none }
my-courses.php:71 @media (max-width: 640px)
.myc-card { flex-direction: column }
The second is sensible and was left alone. The first deletes the editor's lesson list β which is also where Add lesson lives β and the player's table of contents. Both are primary navigation.
β That is worse than not supporting a screen. A module that says "this needs a bigger display" is honest. One that silently removes the way to move around leaves a page that looks complete and quietly cannot be used: a course could only be paged through one lesson at a time, with no sense of where you were in it, and the editor could not add a lesson at all.
Both come back as a slideβin panel with a button to open it, borrowing LAYER 4's sheet chrome so it reads like the ticket sheets. Styled in place rather than having the node relocated β nothing else needed the DOM moved, and not moving it means the editor's own JavaScript keeps working untouched.
The replacement panel is gated at the rollout's 768px. The rule that removes the originals started at 900px.
So every width from 769px to 900px β a tablet held upright β had the panels deleted and no button to bring them back, because the replacement had not started yet. Measured at 800px: contents hidden, no button, and the page overflowing by 149px.
π A rule that removes something and a rule that replaces it have to share a breakpoint. Two numbers that are nearly the same leave a band where the first has happened and the second has not.
lms.css now hides them at 768, so above that you get the real 250px sidebar and below it the panel. Verified at every width from 1100 down to 360: the contents are reachable at all of them, with the handover exactly at 768.
The clearest worked example of Β§11 in the rollout so far, because all four wear the same class (.lms-table) and none wants the same treatment. They are told apart by the tbody ids the page already gives them β no markup change and no :has() gymnastics needed for the JS.
| table | columns | verdict |
|---|---|---|
#coursesBody |
Title Β· Version Β· Uploaded Β· Status Β· Actions | feed β you are looking for one course, not comparing five |
#groupsBody |
Name Β· Description Β· Members Β· Actions | feed, without a second consideration: the column holds prose |
#assignmentsBody |
Course Β· Group Β· Deadline Β· Assigned by Β· Actions | feed β and note Course and Group are two names side by side |
#progressBody |
Analyst Β· Course Β· Group Β· Status Β· Score Β· Deadline Β· Last access | scroller β a report, read down Status and Score across people. Seven columns cannot be carried by reading order. π΄ Reversed to a feed in #1401 β see below |
.lms-table thead. That was originally to protect the progress scroller's header row; all four are feeds now, so a blanket rule would reach the same cells β but Β§21 reads its labels out of those header cells, so the list of tables the harvester knows about and the list whose head is hidden have to stay visibly the same list.
Β§21 labels only the values that cannot speak for themselves: an upload date, a member count, and β on assignments β the group, deadline and assigned by, because a pair of names is unreadable unlabelled whatever the column count says. The version column is left bare: it is a badge reading Authored or SCORM 2004, which says what it is.
Β§26 says a sweep is owed across the shipped modules. Running it on a module as it is opened is cheaper, and it found one immediately:
.lms-lesson-item:hover .lms-lesson-del { opacity: 1 }The perβlesson delete button in the course editor, invisible until a pointer is over its row. On a phone it occupied its share of every lesson row and could never be seen β tappable by accident, never on purpose. Second instance found since the technique was written.
Worth recording because both were found by reβmeasuring after the change rather than before it.
The injected button broke the editor. LAYER 29d puts a "β° Lessons" control into .lms-editor-bar, and that pushed .lms-editor-bar-actions from a row that just fitted to one ending at x=451 on a 360px screen. The editor went from contained to +91 the moment the panel was given back.
π Adding chrome to a row is the same act as adding a child to a grid (GH #122): it is a layout change, and the row it lands in has to be able to take it.
The button printed a translation key. It rendered as the literal string β° lms.player.contents. The player's panel has no heading to harvest, so the fallback was not decoration β it was the label on one of the two screens β and lms.player.contents does not exist.
π A missing key returns the key, which is truthy.
t(key) || 'Contents'therefore never fires. The guard has to compare against the key itself. Both screens now uselms.editor.lessons, which is accurate for both β a course's contents is its list of lessons β and needs no new strings.
The sheet appeared not to open. After tapping the button, getComputedStyle reported visibility: hidden and the panel still translated offβscreen β while element.matches() confirmed the rule did match. Headless Chrome does not run CSS transitions, and both properties the open rule sets are transitioned, so the computed value never leaves its start. Setting transition: none first showed the panel at translateX(0), visible, 310Γ740, with content inside returning from elementFromPoint.
The desktop control looked alarming. It reported the courses table as display: block with its header hidden "at 1100px" β until the @1100 in the output turned out to say @360. An argument in the wrong position meant the run had never left mobile width. The real 1100px result is a normal table with table-header-group.
π Both are the same lesson in different clothes: check what you actually measured before believing what it says. The harness prints the width it used for exactly this reason.
-
All six pages contained at 360px, from
docScrollW=953. -
Desktop control at 1100px: the courses table is a normal
tablewith its header row shown; the player's contents are astatic250px sidebar; the panel button isdisplay: none; the tab strip is not a scroller; and nodata-mobile-labelis stamped anywhere β Β§21's own stronger check, since the harvester is meant never to touch a desktop render. -
The Β§25 audit: every change to an LMS page file is a
<body>marker, and every change outside the module is a?v=bump. - The panel sheet driven open on both the player and the editor, with
elementFromPointconfirming its contents are reachable rather than trapped behind the scrim.
Between roughly 769px and 949px both the player and the editor overflow β docScrollW=949 whatever the viewport, so something in that shell has a ~949px minimum width. This predates this work and is unchanged by it (measured at 800px before and after), and it sits above the mobile gate, so it has been left alone and recorded rather than fixed as a side effect.
The list rows carried padding: 12px 4px where every other card feed in the file uses 12px 14px β so LMS course titles sat 10px closer to the edge than the identical card in Contracts. Measured: text at x=16 here against x=26 there.
Not broken, just different β and the difference was the whole complaint. A reader moving between two modules notices one of them crowding itself.
π A value invented per module is a value that will drift. It had simply been typed differently.
All three LMS shells sized themselves that way, and on iOS 100vh is the large viewport β the height the page would have if the browser chrome were hidden. So each shell is taller than what you can see, and anything pinned to its bottom edge is drawn behind the address bar. The 48px is a second assumption stacked on the first: the header's height, but only while it has not wrapped.
The rollout's answer since LAYER 2 is to stop guessing: <body> is already a 100dvh flex column, so the shell takes what is left. Plus env(safe-area-inset-bottom) on the bar itself.
100vh and 100dvh are identical on a desktop, so lms.css keeps its own value.
init() {
initTinyMCE(); // async: TinyMCE sets up in the background
loadLessons(); // async fetch -> selectLesson() -> if (editor) editor.setContent(...)
}setup fires when TinyMCE starts initialising, not when it can be written to. Lose the race and editor is still null, the guard is false, and the lesson body is silently discarded. Refreshing runs the race again and usually wins.
π
if (thing) use(thing)is not a guard, it is a coin toss whenthingis populated asynchronously. It converts a timing bug into a silent data loss, and reports it as "sometimes".
Fixed by waiting on the editor's init event and holding the content until then, so the order stops mattering. This affected the desktop too β a slower phone just lost more often β and is therefore not gated to mobile.
On a desktop the lesson pane is one column: title, editor, save, questions. Fine with 900px of height, unreadable with 400 β you scroll past an entire rich-text editor to reach the questions and back again to save.
Split behind a switch, with the three scattered actions (Course settings and Preview from the top bar, AI: write this lesson from inside the pane) relocated into a bar along the bottom. Each node remembers its original parent and next sibling, so above 768px it goes back exactly where it was β verified: the top bar reads "Course settings | Preview" again, the AI button is back in its row, and the page attribute is cleared.
β The first tab is named after the lesson. There is no existing key for "text"/"content", and one word is a poor reason to fan out to 24 locales β but on a phone the lesson list is behind a sheet, so the screen otherwise never says which lesson you are editing. The name answers both. lms.editor.questions supplies the other tab. Zero new keys, and a better bar than a generic one would have been.
The bar's three labels were set to ellipsise. They did not: text-overflow needs a block-level box with overflow: hidden, and these buttons are inline-flex, so the text is centred and simply overflows. On screen "AI: write this lesson" came out clipped at both ends, reading : write this lesso.
The measurements said the bar was 360px wide and pinned at the bottom β which was true. Only the screenshot showed the text cut in half, the fourth time in this rollout a picture has caught what the numbers could not. The labels now wrap, which reads better than truncation anyway: three short lines are legible, a truncated instruction is not.
Ten separate pieces of feedback, worked one at a time. Three of them were faults this rollout had introduced, one was a bug that had nothing to do with phones at all, and one reversed a decision documented further up this page.
.lms-questions-head is a flex row holding a heading and two buttons β AI: write questions and + Add question β three things across 360px. Stacked, with the buttons sharing the width below at 42px tall.
Also dropped, on the questions page only, the 34px margin / 22px padding / top border that divides the questions from the editor above them. On a page where the questions are the only thing there, that separator divides the heading from nothing β a leftover of the two-page split from #1399. Scoped to [data-lms-page="questions"], so the desktop keeps its divider.
The four-tables table above called Progress a scroller, on the reasoning that it is a report read down the Status column across people. Ed's feedback was simply that it scrolled left and right.
He is right, and the reasoning was right about the wrong device. At 360px the scroller shows three of eight columns β so the comparison it was protecting was never available on a phone in the first place. Written up as Β§11's follow-up question: would they compare? is a desktop question; can they, on this screen? comes first.
It is a card feed now: the analyst is the heading, the status rides beside it, and course / group / score / deadline / last access follow as Β§21 labelled lines. Five labels is more than any other feed here carries, and that is the honest price of a seven-column report β two of them are names side by side and two are dates side by side, and neither pair can be told apart by position once the header row is gone.
The view button is taken out of the flow and pinned to the card's bottom-right corner. In flow it claimed a whole 40px row to hold one 20px icon; position: absolute on a position: relative row removes the row entirely. Two details that are not obvious:
- It is inset by the card's own padding (
right: 14px; bottom: 12px), not pinned to the corner βright: 0aligns to the padding box, so the button sat hard against an edge that nothing else on the card touches. Ed asked for "the same padding as the pills", and the pills' padding is the card's padding. Measured: the button's right edge and the status pill's right edge both land on 326. - The two date cells reserve
padding-right: 62px, because one of them is always the card's last line. Score is never last and is deliberately not padded.
Four selects β course, group, analyst, status β stacked into four full-width rows and spent most of the screen before a single result. They move into a LAYER 7 sheet behind one filter icon on a bottom bar.
Moved, not rebuilt. Each <select> keeps its id and its inline onchange="LMS.loadProgress()", so the page's own filtering works with nothing rewired β the same trick the Knowledge sidebar uses, and each node remembers its original parent and next sibling so it goes back exactly where it was above 768px.
β One new word, in common. The visible control is an icon, but the aria-label and the sheet's title need a real string. common.filter was added to all 25 locales rather than a fourteenth private copy of "Filter" in a module namespace β the generic word belongs where the next module will find it.
Ed, two messages later: "for some reason on courses and progress screens I can't scroll at all."
<body> is a 100dvh flex column, so every direct child is a flex item that shrinks to fit β and .lms-container is a plain padding: 20px div. It was squeezed to the viewport, its content spilled out with overflow: visible, and nothing could scroll: not the container (no overflow set) and not the document (already exactly 100dvh).
π It had passed four rounds of measurement because it LOOKED right. Every card rendered, in the right place, at the right width, nothing overflowing. The harness has only ever asked is anything wider than the screen? β never can you reach the bottom of it?
Now Β§28, with the one-line check and an audit owed across every shipped module. Two details worth carrying: min-height: 0 is not optional on the container, and the harness needs a realistic viewport height β the bug is invisible at height: 900, which is what the LMS probes had been using because the interesting question was always horizontal.
Ed asked for dummy data so the screen could be judged. The reason it had none is worth more than the request: the LMS demo module created courses and lessons but no groups, assignments or progress, so the tab where a manager actually lives came up empty on a fresh install of the product.
Fixed in database/demo-data/lms.json rather than by hand: two groups, a course assigned to each, and progress covering every state the screen renders β passed, failed, incomplete, completed, not started β plus one overdue. Seven rows. That needed is_demo on four more tables (freeitsm.sql + db_verify_schema.php), which is what lets the importer clear its own rows without touching anyone's real ones.
A fourth filter, by analyst. Ed asked to be pushed back on if it were risky alongside the group filter. It is not: the two narrow different joins, so they compose β picking somebody who is not in the chosen group correctly returns nothing, rather than one filter quietly winning.
β The dropdown lists only the people the current course/group selection covers, built from the same query without the analyst filter applied β otherwise choosing somebody empties the list you chose them from. And a selection that drops out of the list (a course filter that excludes them) is kept in it rather than silently reset, because resetting to "All analysts" would show rows nobody asked for.
An overdue row is marked background: #fff5f5 β a pale tint behind a desktop table row, and unremarkable. As a card it is a near-white block filling the width of the screen, and in a dark theme the text on it is the theme's own light grey. The analyst's name all but vanished, on the one row in the list that is trying to shout.
First attempt was the theme-aware pair, --danger-bg with --danger-text. Legible β and wrong, because the "Overdue" pill is painted from the same danger pair, so the badge landed on a background of its own colour and stopped reading as a badge.
β The answer was an edge, not a tint. A 4px
border-left: var(--danger-accent)marks the card without touching a single one of its own colours, so every label, pill and value reads exactly as it does on the six cards around it.
lms.js to a class first β an inline style cannot be overridden without !important. The class carries the identical #fff5f5 in lms.css, so the desktop table is byte-identical; the same latent dark-theme problem is still there, over a much smaller area, and was left rather than fixed as a side effect of mobile work.
"I am a bit confused about where the preview button takes me." Preview opens the real learner player in the same tab, and the player's back button went to the course list β three clicks from the lesson you had been editing. from=editor sends you back to the editor instead, checked against the capability rather than just the URL parameter, and reusing common.back so no new string was needed. Computed in player.php above the native handoff, so the authored-course player β which draws its own toolbar β gets the same answer.
The lessons panel stayed open on top of what you had just asked for. The delegated handler already closed the panel when you chose a lesson, for exactly this reason. Its selector was a, .lms-toc-item, .lms-lesson-item, and the panel's two footer controls are plain <button>s. Both close it now β not just the reported one: + Add lesson starts a new lesson in the pane behind, which is the same fault with a quieter symptom.
Ed had the AI draft a course and every navigation between lessons asked whether to discard changes he had never made.
isDirty() compared the lesson as the API returned it against editor.getContent(). Those are two representations of the same document: TinyMCE re-serialises whatever it is given, and by default (entity_encoding: 'named') writes non-ASCII back as named entities. Measured on his own lesson:
stored ...example β a Customers table...
getContent() ...example — a Customers table...
First difference at index 154; 402 bytes against 414. The lesson was modified from the instant it loaded.
π A dirty check must compare like with like. Take the baseline from the editor, immediately after the content lands in it, so whatever normalising the widget does has already happened on both sides.
β It stayed hidden for as long as it did because content typed into the editor is saved in the editor's own encoding and therefore compares equal on the way back in. Only text reaching the database without passing through TinyMCE is affected β which is exactly what an AI draft is, and AI writing uses em dashes and curly quotes as a matter of course.
setEditorBody and in TinyMCE's init handler, because #1398 made that write deferrable and the baseline has to follow the content, not the request to set it β and re-taken after a successful save, which was the same fault more quietly.
The last one, and the one worth sitting with.
TinyMCE's fullscreen plugin fixes the editor to the viewport and sizes it with its own height. LAYER 29f answers a different question β how tall the editor should be while it is one of two things in a flex column β and answers it with height: auto !important, which beats the plugin. Measured at 360x740: position: fixed at 0,0, 360 wide and 240 tall, because min-height was then the only declaration left with an opinion.
Two halves to the fix, both now Β§29: scope the everyday rule with :not(.tox-fullscreen) and answer the state explicitly in 100dvh; and hide the injected chrome, because .lms-action-bar and the plugin share z-index 1200 and the bar β later in the document β was painting over the bottom of the editor.
π΄ The rule that broke it was written three items after the change that moved the full-screen button to the front of the toolbar on a phone, a change made because it is the one control that rescues a small editor. The control was made easier to reach in the same round that stopped it working. When a round makes a control more prominent, that control has earned a test.
Every item was measured on the real page through the same-origin harness, with a desktop control at 1100px on anything that touched shared CSS.
-
Progress at 360px: contained, seven cards, table
scrollWidth === clientWidth === 336, and the eye's right edge on 326 β the same pixel as the status pill. - Both console tabs scroll to their last row (gaps of 358 and 253, reached in full) at 360x740.
-
Desktop control at 1100px: a real
<table>withtable-header-group, the bardisplay: none, the filters back in the panel header, the body attribute cleared, and zerodata-mobile-labelstamped anywhere. - The filter API exercised across five combinations, including analyst-plus-group with no overlap.
- The dirty check driven for real: walking all seven AI-drafted lessons raises zero prompts (it was one per navigation), and a genuine edit followed by a navigation still raises exactly one.
-
Full screen clicked at 360x740: 0,0 at 360x740, bar and tabs
display: none, andelementFromPointreturning the editor's own iframe at the centre and TinyMCE's status bar at the foot.β οΈ The 1100px control droveexecCommand('mceFullScreen')rather than the button β at that width the button sits in TinyMCE's floating overflow and the probe's selector did not reach it. Same plugin path, but a command-level check, not a click-level one. -
The Β§25 audit on every commit: outside
assets/,lang/and the LMS pages, every changed line is a?v=bump and nothing else.
Competency tests arrived in 3.0.0, after this module's rollout, and shipped without mobile.css, mobile.js or a body marker - so on a phone they were desktop pages. Three analyst pages (the list with its Tests / Question bank / Candidates tabs, the test builder, a candidate's result) share one shell, lms/tests/_page.php, so opting in was one include plus a marker per page: data-mobile-module="lms" and data-mobile-page="lms-tests" / -edit / -result. The candidate's own page, lms/test.php, was built for a phone from the start (16px text, one 760px column, a pinned bottom bar) and was left alone.
No tests, questions or sittings on the dev install - Β§30's third form. The probe stubbed fetch in the frame with fixtures shaped like api/lms/tests.php's replies (its own state and status vocabulary: ready / in_progress / submitted / expired, draft / approved / hidden, choice / graded), swallowed every POST, and re-ran lms-tests.js so the page's own renderers drew everything. Nothing written to the database. Fixtures deliberately carried the worst case: a 70-character test title, a 60-character candidate email, a candidate with no email, every sitting state.
docW 360/360 <- green, and meaningless: .ct-scroll declares overflow-y
alone, so it scrolled SIDEWAYS (Β§12) and swallowed:
Candidates table 13 crushed cells, status / score / actions off the right
Tests table Candidates column and actions off the right
question cards actions a right-hand column, the question ~110px wide
builder `200px 200px 1fr` grid - title and role fields past the edge
six-column skill row; 20 fields at 13-14px (Β§3 zoom)
-
Shell -
.ct-pageis a direct child of LAYER 2's flex body and sized itselfheight: 100vh, the iOS large-viewport fault from #1399; it takes the remainder instead, and.ct-scrollgetsoverflow-x: hiddenso nothing can hide sideways again (LAYER 33's lesson). -
Both tables are card feeds, the courses feed's exact shape so they read as the same module. The candidate's status pill rides beside the name, keyed on
td:has(> .ct-state)rather than a position, because status is column 3 on the Candidates tab and column 2 in the builder. Β§21 labels the bare counts (questions, candidates), the test name, the score and the two dates - three newFEEDSentries, each withwatchbecauselms-tests.jsbuilds the whole table into its container. - Question cards - the actions drop to their own row under the question.
- Builder - the role grid two-up with title and description full width; each skill row a small card (name / difficulty + format / count + Fill + remove).
- Bank filters - search full width, the four selects two-up on a grid (Β§10).
- Result - the six facts two-up, each skill name over its bar, "β their answer" under the chosen answer.
-
16px on every field across the pages and both dialogs, written as an exclusion (
input:not([type=checkbox]):not([type=radio])) so a new field type is covered by default.
Β§21 labels table cells. The skill row is inputs, and an <input> cannot carry a ::before. But the row can, and a pseudo-element is a grid item: mobile.js harvests the hidden head's Questions text onto each .ct-skill as data-mobile-count-label, and the row's ::before takes a named grid area beside the "1 of 6" figure. Translated in every locale, no markup change, and removed again above 768px. Difficulty and format were left unlabelled - a select shows its value, and Intermediate says what it is.
"β their answer" is an ::after on an answer row that is display: flex. display: block on it was inert (Β§27) and it stayed a thin third column. flex: 1 1 100% on the pseudo-element plus flex-wrap on the row fixed that - and then wrapped the mark onto a line of its own, because the answer text, still at its content width, no longer fitted beside it. The text needed flex: 1 1 0; min-width: 0 as well. Two screenshots, two different wrong layouts; the measurements passed both.
- All six screens at 360x740 through the iframe harness: contained, nothing past either edge, no crushed cells, no field under 16px, and the scroller reaches its end on every one (Β§28).
- Both dialogs (question editor, Send) open as full-screen sheets at 0,0 360x740.
-
Desktop control at 1100px on all three pages: every element's box identical with
mobile.csson and off, zerodata-mobile-labeland zerodata-mobile-count-labelstamped. - The probe's one red - five header links past the edge - read the same on the already-shipped courses page: the LMS nav behind the hamburger, not this round (Β§30: a new probe's first red).
- Β§25 audit: outside the
@mediablock the diff is the two opt-in tags and three body markers. The:hoversweep (Β§26) onlms-tests.cssfound nothing.
- MobileβFriendly Β· Techniques & Tricks
- LMS β what the module does
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