Skip to content

Mobile Friendly LMS

Ed Mozley edited this page Oct 6, 2026 · 4 revisions

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.


Where it started

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.


⚠️ The pre‑flight found two @media blocks, and one of them hides navigation

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.


πŸ”΄ Two rules that nearly agree leave a hole

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.

⚠️ This is a change above the mobile gate and was made deliberately rather than left β€” it is the same fault as #1390's tablet band on the portal, found for the same reason.


Four tables on one page, and Β§11 answers them differently

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

⚠️ The header row is hidden per table, not with a blanket .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.


The :hover sweep, run on a new module

Β§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.


πŸ”΄ Two faults I introduced, and how they were caught

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 use lms.editor.lessons, which is accurate for both β€” a course's contents is its list of lessons β€” and needs no new strings.


⚠️ Two measurement artefacts that looked like bugs

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.


How it was verified

  • All six pages contained at 360px, from docScrollW=953.
  • Desktop control at 1100px: the courses table is a normal table with its header row shown; the player's contents are a static 250px sidebar; the panel button is display: none; the tab strip is not a scroller; and no data-mobile-label is 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 elementFromPoint confirming its contents are reachable rather than trapped behind the scrim.

⬜ Known and not fixed

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.


Polish, round one (#1396-#1399)

The one module crowding its own panel

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.

πŸ”΄ calc(100vh - 48px) is why the bottom bar sat under Safari's address bar

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.

⚠️ Mobile-only β€” 100vh and 100dvh are identical on a desktop, so lms.css keeps its own value.

πŸ”΄ The editor that was empty until you refreshed

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 when thing is 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.

Two pages for one lesson (29f)

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.

⚠️ text-overflow: ellipsis does nothing inside a flex container

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.


Polish, round two (#1400-#1410)

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.

The Questions heading had to stop sharing its row (#1400)

.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 scroller that did not survive contact (#1401)

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: 0 aligns 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.

The filters behind one icon (#1402)

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.

⚠️ A single control, so the bar centres it rather than stretching it. A filter button 360px wide reads as a primary action, which it is not.


πŸ”΄πŸ”΄ And then it turned out you could not scroll the page at all (#1403)

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.


The Progress tab was empty on every install (#1404-#1405)

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.


πŸ”΄ A card is not a table row: the overdue tint (#1406)

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.

⚠️ The colour had to move from an inline style in 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.


Two navigation confusions (#1407-#1408)

"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.


πŸ”΄ "Unsaved changes" on a lesson nobody had edited (#1409)

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 &mdash; 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.

⚠️ Not a mobile bug at all, and not new: it affects every lesson containing a non-ASCII character, at any width. Recorded here because this is where it was found. A grep confirmed the codebase has no second instance of the pattern. The baseline is captured in 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.


πŸ”΄ Full screen went full WIDTH (#1410)

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.


How the polish rounds were verified

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> with table-header-group, the bar display: none, the filters back in the panel header, the body attribute cleared, and zero data-mobile-label stamped 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, and elementFromPoint returning the editor's own iframe at the centre and TinyMCE's status bar at the foot. ⚠️ The 1100px control drove execCommand('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.

⚠️ A comment that closes early silently kills the rule after it, and it happened twice in this round β€” both times caught by re-measuring rather than by reading, and both times the brace count and the column-0 gate passed happily. The comment-balance check is now run before every commit alongside them.


Tests - the competency tests pages (LAYER 29t, #2175)

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.

⚠️ The install had no tests, so the first thing fixed was the test

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.

What it measured, before

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)

What it does now

  • Shell - .ct-page is a direct child of LAYER 2's flex body and sized itself height: 100vh, the iOS large-viewport fault from #1399; it takes the remainder instead, and .ct-scroll gets overflow-x: hidden so 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 new FEEDS entries, each with watch because lms-tests.js builds 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.

⭐ A bare count in a row of FIELDS, labelled without touching the markup

Β§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.

πŸ”΄ display: block on a flex item's ::after does nothing

"← 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.

How it was verified

  • 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.css on and off, zero data-mobile-label and zero data-mobile-count-label stamped.
  • 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 @media block the diff is the two opt-in tags and three body markers. The :hover sweep (Β§26) on lms-tests.css found nothing.

Related

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally