# Learning Hub → the mobile app's API

## What it does

Gives the PropertyLab member app (Flutter) the portal's **Learning Hub** (`/property/academy`), all
six tabs, without a second backend or a second copy of any sentence:

| Endpoint (under `/member-api/v1`) | Portal equivalent |
|---|---|
| `GET learning` | the hub: tab strip + e-Learning shelves (`CoursesController@index`) |
| `GET learning/content/{set}` — `glossary` · `cases` · `how-to` · `road` | the four tabs that are JavaScript modules in the web bundle, exported to JSON |
| `GET learning/road` | the **DMAIC 之路** tab's `road` prop, verbatim |
| `POST learning/road/training/enrol` | 「加入 5 天训练」 (`RoadController@enrol`) |
| `GET learning/area-guide` | the **Area Guide** tab's `areaGuide` + `areaTutorials` props, behind the tab's three locks |
| `GET learning/area-guide/areas/{area}/chapters/{chapter}/images/{index}` | `main.portal.area-guide.chapter-image`: the SAME controller method, re-mounted for a JWT |
| `GET learning/courses/{uuid}` · `…/lessons/{uuid}` · `POST …/complete` | e-Learning course / lesson / progress (unchanged, built earlier) |

All need `Authorization: Bearer <member JWT>`. All sit behind `auth:api` and the group's
`throttle:60,1`, plus the contact-verification refusal `MobileLearningController::verifiedUser()`
has always applied. There is **no `feature:` gate**, because `/property/academy` has none on the
website: only the Area Guide tab is gated, inline. Enrol has its own counter,
`throttle:6,1,road-enrol`. The third argument matters because a throttle's key is the USER, not the
route: without it, enrol would share (and double-count) the group's 60/min.

The Flutter engineer's copy of the contract, with example JSON for every endpoint and every lock
state, is `propertylab-mobile/docs/app/api/learning.md`.

## How it works

### One controller, extending the portal's

`App\Http\Controllers\Api\v1\MobileLearningController` **extends**
`Main\Portal\CoursesController`, so it holds no rule of the module. Every lock the app shows is one
the website computed:

- `roadState()` builds the road (`RoadPresenter::hub()` + `TrainingAccess::summary()` + the
  `next_round` verdict).
- `areaGuideLock()` decides the Area Guide's three locks, in order, **once** (extracted 2026-09-21
  from `areaGuideState()`, which now reads it):
  1. `section`: the kill switch `features.area_guide_locked`. Admins pass unless they send
     `?preview=locked`.
  2. `trainee`: `TrainingAccess::verdict($user, 'area_guide')`, which opens with the Measure card on
     Day 2.
  3. `gated`: `FeatureAccess::allows($user, Feature::AREA_GUIDE)`. Staff pass.

  `areaGuideLockReason()` gives the sentence (the trainee verdict's own reason, else the membership
  line, else null). The hub's tab strip reads the lock DECISION only, so answering "is the tab
  locked?" never loads a country's stories.
- `areaGuideState()` + `areaTutorials()` build the Area Guide exactly as the website's props do.

### G-23 · The static copy: an export, not a re-type

Four tabs have no server data at all. The Glossary (`utils/glossaryContent.js`), 案例复盘
(`utils/cases/`), How to Use PropertyLab (`utils/portalGuide/lessons.js`) and the road's hub copy
(`utils/road/stages.js` + `content/next.js`) are modules compiled into the web bundle.

- **`scripts/export-learning-content.mjs`** imports those modules **unchanged** and writes one file
  per set to **`resources/learning-content/{set}.json`**, which is COMMITTED. The server reads the
  file; no Node runs at request time.
- **Vite's job is done by loader hooks** (`scripts/learning-content/hooks.mjs`), never by editing a
  portal module:
  - extension-less relative imports (`../areaGuide/countries`) and the `@/` alias;
  - JSON imports with no import attribute (the How-to fixtures);
  - `.vue` files, compiled with the portal's own `@vue/compiler-sfc`;
  - image imports, stubbed to their path.

  No portal module was edited, so the website is byte-identical.
- **Envelope:** `{ set, contract_version, content_hash, generated_at, locale, locales, sources,
  markup, web_path, …, counts }`.
  - `content_hash` is the sha256 of everything except itself and `generated_at`, so re-running the
    export on unchanged modules rewrites nothing.
  - `markup` says how to read inline emphasis: `html-inline` (`<b>` in the glossary) or
    `markdown-bold` (`**…**` everywhere else).
  - Paths inside a set are relative; resolve them against `web_base_url` from `GET learning`. The
    body must be a pure function of the file, because its hash is the ETag. **One exception:** the
    `cases` set's diagram PNGs (below), whose absolute URLs are added at request time and folded
    into the served hash.
- **Strings that live only in a `.vue` file:**
  - the quiz's generated-question template (`QuizPanel.vue`);
  - the checklist's three answers (`CaseChecklist.vue`);
  - the hub's legend, unlock labels and terminal row (`RoadOverview.vue`).

  They are listed in `VUE_LITERALS` with their source file, and the export FAILS when that file no
  longer contains one.
- **Staleness guard:** `node scripts/check-learning-content.mjs` rebuilds every set in memory and
  compares it byte for byte with the committed file (a hand-edited JSON fails too). It runs in CI's
  `tests` job, after `npm ci` (it needs `node_modules`), and in
  `MobileLearningContentTest::test_the_committed_json_is_not_stale_against_the_portal_modules`.
  **If you edit any of those modules, run `node scripts/export-learning-content.mjs` and commit
  `resources/learning-content/`.**
- **Serving:** `Src\Lms\Services\LearningContent`.
  - The route constraint *is* the whitelist (`LearningContent::SETS`), so any other set is a 404
    before a controller runs.
  - `ETag` = `"content_hash"` as SERVED. `If-None-Match` is matched on the hash, so a CDN-weakened
    `W/"…"` or a mod_deflate `"…-gzip"` still revalidates, and a match returns an empty **304**. For
    `cases` the served hash is not the file's (see *Case diagrams as PNGs*).
  - `Cache-Control: private, max-age=3600`.
  - **Fallback:** when a file is missing, the service runs the exporter (`--set=… --stdout`)
    through `Symfony\Process`: array command (no shell), 20 s timeout, stderr to the log, the answer
    cached 1 h, a failure remembered 60 s. It never throws; an unavailable set is a **503**.

| Set | Size | What is in it |
|---|---|---|
| `glossary` | 48.8 KB | 8 chapters (slug, letter, title, blurb, why, news, lesson), **64 terms** (en · zh · def), 45 FAQ, 22 real cases with their news source, 8 scenario questions; the four steps; `quiz_generator` (章节测验 adds 2 questions generated from the chapter's own terms, so the app's drill is the portal's) |
| `cases` | 57.4 KB | 1 case, **8 screens, 41 points** (title, line, calc, character, terms, viz); facts, objectives, rules, recap; the last screen's 6-question checklist + verdicts + tool links; the term dictionary the points name |
| `how-to` | 100.6 KB | 3 sections, 3 groups, **27 lessons (7 Soon), 119 points**, TEXT only: `component` dropped, `preview` reduced to `picture: { id, alt }`; `doNext` CTAs; the term dictionary; per lesson its `learning_lab`; per section its hub `art` `{ label, title, line, short, image, cover }` (`utils/portalGuide/sectionArt.js`, photos as `.webp` paths, since 2026-09-25) |
| `road` | 8.1 KB | **8 stations** (起 D M A I C T + 下一轮): name, question, "你会得到", 「这一站，学会什么」 (`learns`, `HUB_ROWS` in `content/next.js`, since 2026-09-25), minutes, cover, tool; the 9-point intro; the badge legend; unlock labels. **Not** the stage reading (G-38) |

The audit (2026-09-20) counted 20 How-to lessons with 1 Soon; the module has grown since, and the
export is the module's truth.

### What could not be exported as data

| What | Count | Exported as |
|---|---|---|
| 案例复盘 diagrams (Vue/SVG, `Lms/Partials/Cases/Viz/` + `demand-driver-icons` from the road) | **9 kinds, 34 frames** (`case-site-map` 3 · `assumption-cards` 3 · `pocket-land` 5 · `monthly-gap` 6 · `demand-chain` 3 · `demand-driver-icons` 1 · `case-scoreboard` 5 · `case-rules` 4 · `hold-horizon` 4) | `viz: { id: "kind:state", kind, state, alt }`. `alt` is the `aria-label` the component writes for itself, read by rendering it once headless (`vue/server-renderer`). A frame with no aria-label fails the export. **Served with its PNG** since 2026-09-21 (owner's ruling, contract §12.2): `+ image_url, image_width, image_height` — see *Case diagrams as PNGs* below. |
| How to Use PropertyLab previews (the REAL portal component mounted with captured data, element spotlit) | **93 distinct frames** (`key#anchor`) over 119 points | `picture: { id, alt }`, where alt is the preview's caption. The tab is served **`native`** (2026-09-21). **Served with a screenshot** since 2026-10-01: `+ image_url, image_width, image_height`. See *Learning pictures* below |
| The Learning Lab's drawing (`PropertyScene.vue`, three steps; `layers`, or `map` for `kind: evidence`) | **6 images** | `learning_lab.scene: [ {image_url, image_width, image_height} ×3 ]` on every lab in every set, since 2026-10-01. See *Learning pictures* below |
| Glossary 互动演示 (slider demos; thresholds live in `DemoPanel.vue`'s script) | **8** (one per chapter) | `demo: { available: true, native: false, id, web_path }`; `steps[].native = false` for `demo` |
| DMAIC stage reading (nine-beat steppers) and its SVG visualisations | 58 viz / 106 Vue files | not exported (G-38): each station has a `web_path` |

### Case diagrams as PNGs (contract §12.2)

Owner's ruling 2026-09-21: *"exported as PNG by the existing headless render; the app draws
`image_url` when served"*. The app already draws `viz.image_url` (`case_reader_screen.dart`) and
falls back to the alt text when there is none.

- **`scripts/render-case-diagrams.mjs`** paints every frame the `cases` set names (34 today):
  1. `renderViz()` — the export's OWN server-side render (the one that reads the alt text), so the
     picture and the alt come from the same render function. The components animate in on mount
     (`useVizReveal`); the render runs with a `window.matchMedia` stub that reports reduced motion,
     which makes them start in their final state.
  2. The HTML goes into a page that loads the site's **built** stylesheet (`public/build`, found via
     its Vite manifest) and Inter from Google Fonts (as `app.blade.php` does), on white, inside a
     **360 CSS px** `@container`. That is the width the portal's case reader gives a diagram on a
     phone, which is where the app shows it. At the desktop's ~520 px an 11 px label would shrink to
     ~7 pt in the app's card; at 360 it stays ~10 pt. Container queries (`@md:`) resolve as they do
     on a phone.
  3. Headless Chrome measures every frame's painted box in ONE page (`--dump-dom`: the union of
     the frame's element boxes, not descending into an `<svg>` or a clipping box). It then
     screenshots each frame with its window sized to that box + 12 px of white, at device scale
     factor **3** (1,152 px wide), with reduced motion forced. Each PNG's pixel size is checked
     against the box.
  4. When ImageMagick `convert` is installed, each PNG is re-encoded as an 8-bit palette with no
     dithering and no timestamps (about a third of the bytes, no visible change on these flat
     diagrams). An unchanged frame re-encodes to the same bytes, so a re-run is a no-op in git.
  - Output: `public/learning-content/cases/<kind>--<state>.png` (34 files, ~1.1 MB) and the
    manifest **`resources/learning-content/case-diagrams.json`**: `{ generated_at, content_width,
    scale, padding, frames: { "<kind>:<state>": { path, width, height, sha256, source, html_sha256 }
    } }`. `width`/`height` are CSS px at 1×. `sha256` is the PNG's hash. `html_sha256` is the hash of
    the frame's rendered HTML.
  - It **fails** rather than paint something wrong: no `public/build` (or its CSS), Inter not
    loaded (no network), a frame that paints nothing, a PNG of the wrong size. It **warns** when the
    rendered HTML uses a class the built CSS does not define (a `public/build` older than the
    component).
  - It needs Chrome (`CHROME_BIN`, default `/usr/bin/google-chrome`), network and `node_modules`.
    CI has no Chrome, so **nothing in CI runs it**. The export and its guard never depend on it, and
    `cases.json` does not change.
- **Serving:** `Src\Lms\Services\CaseDiagrams` reads the manifest and keeps only well-formed
  entries whose PNG is on disk, so the app never gets a URL that 404s.
  `MobileLearningContentTransformer` then gives every point whose `viz.id` has a frame:
  - `image_url` = `asset(path)?v=<first 12 hex of sha256>`, absolute and cache-busted. Cloudflare
    and the app's image cache key on the URL, so a re-painted picture is never served stale.
  - `image_width` / `image_height` in CSS px at 1×, for the aspect ratio.

  A frame with no picture keeps exactly `{ id, kind, state, alt }`. The other three sets are
  untouched.
- **ETag:** the added URLs are not in the file, so for `cases` the served `content_hash` (and ETag)
  is `sha256(file content_hash + the attached images as JSON)`. A re-painted PNG, a new host, or a
  picture missing from disk changes the validator exactly when it changes the body. The controller
  reads the hash **after** transforming.

**Regenerate the PNGs when a case diagram changes, or a case names a new frame:**

```bash
npx vite build                                   # only if public/build is older than the component — NEVER in the live tree
node scripts/render-case-diagrams.mjs            # all frames (~1 min); --only=monthly-gap:gap for one
node scripts/render-case-diagrams.mjs --check    # no Chrome: exit 1 when a PNG is missing or its component renders differently
# look at every changed PNG, then commit public/learning-content/cases/ + resources/learning-content/case-diagrams.json
```

`--check` compares each frame's rendered HTML with `html_sha256`, so it catches a changed
component. It cannot see a CSS-only change, such as a Tailwind token. PNGs no case names any more
are deleted by a full run.

### Learning pictures — How-to screens and the lab drawing (2026-10-01)

The owner asked for the app to show the website's pictures. Two of them are live Vue that no export
can turn into data, so **`scripts/render-learning-pictures.py`** screenshots them from the website
itself. It uses Playwright for Python and Chrome, at a 390 × 844 phone viewport, ×3, as WebP:

- **How-to:** it opens every lesson, steps to each point that has a preview, and screenshots the
  `[data-live-preview]` frame. The caption line is hidden, because the app prints `alt` under the
  picture. The page's own pinned bars are hidden too: the portal's mobile header was otherwise
  painted over the frame. Files go to `public/learning-content/how-to/{zh,en}/<lesson>--<point>.webp`,
  121 points per language (~6 MB each).
- **Lab drawing:** it opens one lesson per variant, clicks each of the three steps, and screenshots
  the `PropertyScene` SVG with the step buttons hidden. Files go to
  `public/learning-content/lab/{layers,map}-{1,2,3}.webp`. The drawing has no words, so one set
  serves both languages.

The manifest is `resources/learning-content/learning-pictures.json`. `Src\Lms\Services\LearningPictures`
reads it; it never throws, and an entry whose file is missing means "no picture".
`MobileLearningContentTransformer` attaches the pictures to points keyed `<lesson>/<point>` and to
every `learning_lab` in every set, then folds what it attached into `content_hash` (the ETag), the
same way it does for the case diagrams.

**The learning language comes from the account** (`auth.user.learning_lang`), not the URL. The script
rewrites that prop in the served page, so no account setting is written. It needs a signed-in session:

```bash
MINT_USER_ID=<any member id> php artisan tinker scripts/mint-session-cookie.php   # prints NAME=VALUE
LEARNING_SESSION_COOKIE='NAME=VALUE' python3 scripts/render-learning-pictures.py      # ~25 min, both languages + lab
#   --only=how-to | --only=lab      one part (the manifest keeps the other)
# look at a sample, then commit public/learning-content/{how-to,lab}/ + resources/learning-content/learning-pictures.json
```

**Run it again when a How-to lesson changes, or a previewed portal screen, or the lab drawing.**
The screenshots are of whatever the site SERVES, so build first. Nothing checks them automatically,
because CI has no Chrome and no session.

The case catalogue card's picture is plain data, not a screenshot. `cases[].cover`
(`{ image, alt, place, headline[], note }`, from the case module's `cover`) is read by both
`CasesTab.vue` and the app.

### G-24 · The road

- **`GET learning/road`** is `roadState()` passed through `App\Http\Transformers\v1\MobileRoadTransformer`,
  which renames nothing and drops nothing. `MobileLearningRoadTest` asserts the API equals the
  website's `road` Inertia prop for the same member once the additions are removed.
  - Additions: `contract_version`, `stages[].web_url`, `can_enrol` (= `training === null`, the same
    rule the hub uses to show the button), and `links` (absolute forms of every portal path, the
    card each locked unlock points at, and `enrol_endpoint`).
  - `enrol_href` stays because it is part of the prop, but it is the website's session route and
    means nothing to a JWT caller.
- **`POST learning/road/training/enrol`** runs **`App\Actions\Journey\EnrolInTraining`**, which is
  now ALSO what the website's `RoadController@enrol` runs.
  - The Action holds the cohort rule (latest session registration's slot), the activity trail
    (actor and lead passed explicitly, because the logger's default actor is the WEB guard) and the
    `training.enrolled` staff alert (fail-soft). Staff leave no trail. The three member messages
    are its constants.
  - Answers **201** `{ created: true, message, road }` the first time, **200** `{ created: false,
    … }` after (idempotent), **429** after 6/min. A user with no lead gets one first, through
    `UserRepository::ensureLeadForPortal` — what the website's `main` middleware does on every
    visit (owner's ruling 2026-09-21; it answered **422** before). Lesson progress does the same.

### G-25 · Area Guide

`GET learning/area-guide` → `App\Http\Transformers\v1\MobileAreaGuideTransformer`, two shapes:

- **Not open:** `locked`, `hidden`, `lock_kind` (`section` | `training` | `membership`), the
  server's `reason` verbatim, `day`, `cardHref`, `required_card`, `card_url`, `web_url`. The keys
  that carry the guide (`countries`, `tutorials`, `pinnedArea`, `soonAreas`, `source`,
  `unavailable`) are **absent**, not empty.
- **Open:** additionally `admin_preview`, `source`, `unavailable`, `pinnedArea`, `soonAreas`,
  `countries` (the registry's stories exactly as the website gets them) and `tutorials`
  (`areaTutorials()` cards keyed by area, always a JSON object). Chapter picture paths are re-pointed
  from the website's session route to the member-api mount (absolute).
- **Never sent:**
  - `mapboxToken` (the app's map source is `GET mobile-api/v1/config`);
  - `mapEnabled`, `gameAreas`, `chatAreas`, `narration` (map, walk, chat and narration have no
    member-api endpoints yet: G-39);
  - `malaysia` (the legacy duplicate of `countries`);
  - `previewUrl` / `exitUrl`.
- The chapter-image mount is the portal's own `AreaGuideMediaController@chapterImage`, **without
  `feature:`**, exactly as `routes/main.php` mounts it. The method's
  `AreaGuideViewerAccess::allowsGuide()` is all three locks and answers a plain 403. The middleware
  would answer a locked, non-JSON image request with an HTML page.
- The state endpoint carries no `feature:` middleware on purpose: a member the matrix shuts out must
  still be TOLD which tier opens it, and a 403 cannot say that.

### G-26 · Honest tabs

`GET learning` → `tabs[]` from `App\Http\Transformers\v1\MobileLearningTabsTransformer`, in the
portal's order: How to Use PropertyLab · DMAIC 之路 · 案例复盘 · Area Guide · e-Learning · Glossary
(`MobileLearningTest` reads the order and labels from `Lms/Index.vue`). Each tab has `key`, `label`,
`availability` (`native` | `portal`), `hidden`, `locked`, `lock_reason`, `lock_kind`,
`content_set`, `endpoint` and `web_url` (absolute `…/property/academy?tab={key}`).

- `availability` is `native` for every tab, how-to included (the owner ruled 2026-09-21 that the
  app reads its lessons as text, each live preview printed as its caption).
- Only Area Guide can be `hidden` (the kill switch) or `locked`.
- The old keys (`contract_version`, `tabs[].key/label/availability`, `courses`) are unchanged.
  `web_base_url` is new.

## Deploy

- **`php artisan route:cache`**: five new routes. No config, migration or seeder.
- The JSON is **committed**, so the deploy script must NOT regenerate it: the committed file is what
  CI checked. Regenerating on the box would hide a forgotten commit. The same goes for the case
  diagram PNGs and their manifest: they are committed, served as static files from `public/`, and
  never painted on a deploy (the box needs no Chrome).
- The fallback needs Node + `node_modules` (both present wherever `npm ci` runs); if they are
  missing, a missing file is a 503 rather than an error.
- Horizon holds no changed code.

## Later (out of scope here)

- G-36: the video-watch beacon under member-api.
- G-38: stage reading, cards, playgrounds, the maths read-out.
- G-39: Area Guide narration, chat, walk and map under member-api.
- An image export of the 93 How-to frames. (The 34 case frames are done: *Case diagrams as PNGs*.)
- Lifting the 8 glossary demos' thresholds into `glossaryContent.js`.

## Related files

**Backend**
- [app/Http/Controllers/Api/v1/MobileLearningController.php](/app/Http/Controllers/Api/v1/MobileLearningController.php): `catalogue` · `content` · `road` · `enrolTraining` · `areaGuide` (+ the e-Learning methods).
- [app/Http/Controllers/Main/Portal/CoursesController.php](/app/Http/Controllers/Main/Portal/CoursesController.php): `roadState()`, `areaGuideLock()`, `areaGuideLockReason()`, `areaGuideState()`, `areaTutorials()`.
- [app/Actions/Journey/EnrolInTraining.php](/app/Actions/Journey/EnrolInTraining.php): the one enrol, shared with `RoadController@enrol`.
- [src/Lms/Services/LearningContent.php](/src/Lms/Services/LearningContent.php): reads the committed sets; the Node fallback.
- [src/Lms/Services/CaseDiagrams.php](/src/Lms/Services/CaseDiagrams.php): reads the case-diagram PNG manifest (only frames whose PNG is on disk).
- [app/Http/Transformers/v1/](/app/Http/Transformers/v1/): `MobileLearningTabsTransformer`, `MobileLearningContentTransformer`, `MobileRoadTransformer`, `MobileAreaGuideTransformer`.
- [routes/api/v1.php](/routes/api/v1.php): the `learning.` group (`api.v1.learning.*`).

**Export**
- [scripts/export-learning-content.mjs](/scripts/export-learning-content.mjs) · [scripts/learning-content/hooks.mjs](/scripts/learning-content/hooks.mjs) · [scripts/check-learning-content.mjs](/scripts/check-learning-content.mjs) · [resources/learning-content/](/resources/learning-content/) · [.github/workflows/tests.yml](/.github/workflows/tests.yml) (the guard step).
- [scripts/render-learning-pictures.py](/scripts/render-learning-pictures.py) + [scripts/mint-session-cookie.php](/scripts/mint-session-cookie.php) (by hand) → [public/learning-content/how-to/](/public/learning-content/how-to/), [public/learning-content/lab/](/public/learning-content/lab/) + [resources/learning-content/learning-pictures.json](/resources/learning-content/learning-pictures.json), read by [src/Lms/Services/LearningPictures.php](/src/Lms/Services/LearningPictures.php).
- [scripts/render-case-diagrams.mjs](/scripts/render-case-diagrams.mjs) (by hand, needs Chrome) → [public/learning-content/cases/](/public/learning-content/cases/) + [resources/learning-content/case-diagrams.json](/resources/learning-content/case-diagrams.json).

**Tests**
- [tests/Feature/Api/v1/MobileLearningContentTest.php](/tests/Feature/Api/v1/MobileLearningContentTest.php): 401 / 404 / unverified, every set's envelope, glossary 8 × 64, cases 8 screens × 41 points + alt text + checklist, every case diagram served as its committed PNG (manifest = the set's frames, file hash and pixel size match), a picture only from the manifest and only when on disk, the ETag following the manifest, how-to carries no Vue (only a picture's alt and its painted image), How-to screens and lab drawings only from the learning-pictures manifest with the ETag following it, road stations = the portal's, ETag / 304, the staleness guard, the Process fallback + its failure cache.
- [tests/Feature/Api/v1/MobileLearningRoadTest.php](/tests/Feature/Api/v1/MobileLearningRoadTest.php): road = the web prop in the empty, stale, incomplete, trainee and graduate states; enrol 201 / 200 / 422; the website sees an app enrolment; the throttle prefix is isolated.
- [tests/Feature/Api/v1/MobileAreaGuideTest.php](/tests/Feature/Api/v1/MobileAreaGuideTest.php): kill switch (member / admin / admin preview), trainee, membership, open. In each state the lock equals the website's, no content keys when locked, and the token is never sent; the picture mount; the hub tab.
- [tests/Feature/Api/v1/MobileLearningTest.php](/tests/Feature/Api/v1/MobileLearningTest.php): tab order from `Lms/Index.vue`, availability, web_url, the kill-switch tab, the old keys; the e-Learning endpoints.
