> ## Documentation Index
> Fetch the complete documentation index at: https://theteam-13fc7f49.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Auth User Super Fast — Login → dashboard in one round trip

> Proposal to make the Flutter web app fast from the first byte to the painted dashboard: the app's first load (bundles, 15 fonts, images: measured), then the Login → dashboard transition (8 server round trips today, 1 after), with a phase-by-phase before / after table and the ranked backend / frontend / server / docs steps.

Written on 19/10/2026 from the code on `main` / `chat+ai` in `LMS_Backend`, `LMS_Frontend` and this
site, right after [Auth User Optimize](https://theteam-13fc7f49.mintlify.site/clean-up/auth-user-optimize.md) shipped. That plan made the boot
payload small (1.5 MB → \~20 KB). This one is about the **number of trips** the web app makes between
the Login button and the dashboard, and what each trip waits on.

**Status: CODE DONE ✅ (19/10/2026, Update 126 in the frontend log), SERVER STEPS PENDING ⚠️.**
Every step that lives in a repository shipped on `chat+ai` of `LMS_Backend` and `LMS_Frontend`
(backend suite 127 tests, Flutter suite 850). What is left is done by hand on the servers and is
listed in the table below; until it is done the measured "after" column of §5.1 stays empty.
Scope: the Flutter **web** path from "Login" pressed (credentials valid) to the first painted
student dashboard, with the user's data already in the providers so nothing on the dashboard pops
in later. The Flutter renderer (CanvasKit vs wasm) is **out of scope on purpose** and is not part
of any step below. The F5 / cold-boot path only benefits indirectly (§3 step 1 and step 5 apply to
every request).

### Status per step (19/10/2026)

| Step | In the code ✅ | Still to do on the servers ⚠️ |
| :- | :- | :- |
| 1. Preflights | 1a `max_age` 7200; 1c one `HandleCors`, the Fruitcake + Barryvdh packages removed | **1b** paste the nginx preflight block on `backend-test`, then `dev`, `prod`; verify with the `curl` of §5 |
| 2. No boot work after login | `AppService.bootstrapOnce()` / `applyAuthUser()` | — |
| 3. Statistics off the critical path | `unawaited`, `StudentStatisticProvider.isLoading`, chart loading state | — |
| 4. Login returns the payload | `AuthController::login` → `user`; `LoginResponse.user`; `LoginProvider._applyLoginResponse()`; `LoginPayloadTest`; [Auth §4](https://theteam-13fc7f49.mintlify.site/api-reference/auth.md) | deploy the backend **before** the web build (the old app ignores `user`, the new app falls back to `GET /auth/user`) |
| 5. Server hot path | 5.2 async Turnstile + config secret; 5.3 `UserPolicy` self check first | **5.1** `php artisan optimize` on deploy + OPcache (`opcache.validate_timestamps=0` in prod), optionally "PHP-FPM served by nginx"; **5.4** `CACHE_DRIVER=apc` / `redis` if available; 5.5 / 5.6 are decisions, nothing to run |
| 6. Fonts | 6a Latin subsets (4 × 26 KB), 6c no emoji in the boot-time strings; Light / ExtraBold dropped (§8.1) | **6b** `brotli_types` / `gzip_types` for fonts on the frontend vhosts; 6d (local Roboto) not done, optional |
| 7. Login images | flags 64 px, built-in `authBackground.jpg` 1000 px (§8.4) | **re-upload** the platform auth image at 1600 × 900 in the admin theme page; resize-on-upload in `PlatformThemeController` not done |
| 8. Boot paint | theme cached in `SharedPreferences` (stale-while-revalidate), web boot spinner | — |
| §8.5 Cropper.js | loaded on demand (`cropper_js_loader.dart`), tags removed from `index.html` | — |

The exact nginx / PHP / Plesk commands are in the step texts of §3; the measurement commands in §5.

## 0. TL;DR

Today a successful login costs **4 HTTP requests + 4 CORS preflights = 8 serial server round trips**
before the dashboard route is even built, plus \~250 ms of bcrypt and, in production, a server-side
call to Cloudflare. Measured against `backend-test` on 19/10/2026, each round trip costs \~150–180 ms
of server + network time (§1.3), so the login button spins for roughly **1.7–2.0 s** on a good
connection before the first dashboard frame.

Target after the five steps of §3: **1 request** (`POST /auth/login` returns the token **and** the
same payload as `GET /auth/user`), **0 preflights** after the first one of the session, nothing else
awaited before navigation. Estimated **0.5–0.7 s** to the dashboard frame on the same server, and
\~3× fewer requests on every later page too (step 1 removes one preflight per API call app-wide).

| Step | Repo | Effort | What it removes from the login path |
| :- | :- | :- | :- |
| 1. Cache CORS preflights (`max_age`) and answer them in nginx, not Laravel | backend + Plesk | 1 line + 1 directive block | 3 of the 4 preflights at login, 1 per API call afterwards; the remaining ones stop booting Laravel |
| 2. Stop re-running boot work after login | frontend | small | `GET /platform/theme` + `DeviceInfo` + language init |
| 3. Don't wait for the statistics call | frontend | small | `GET /dashboard/user/{id}/progress` off the critical path |
| 4. Login returns the boot payload | backend + frontend + docs | medium | `GET /auth/user` (merged into the login response) |
| 5. Server hot-path hygiene (opcache / config cache / policy query / Turnstile overlap) | backend + server | small each | 50–150 ms per request |
| 6. Fonts: Latin-subset Poppins, compress fonts at nginx, no emoji in boot-time strings | frontend + Plesk | small | 1 138 KB of fonts before the first frame → \~190 KB; a 709 KB emoji font on the staff first login |
| 7. Images on the login page (5120 px auth background, 979 px flag) | admin upload + frontend assets | tiny | 520 KB → \~95 KB |
| 8. Boot paint: login from the cached theme, a spinner instead of a white page | frontend | small | the 545 ms theme round trip (and the blank page) off the first paint |

**The app's first load is a separate problem with its own numbers (§1.5).** Before the login form
can even be shown, the browser downloads 34 resources / 6.7 MB on the wire: the Dart bundle
(3.3 MB) and CanvasKit (1.6 MB) take \~1 s on a fast link, then the engine pauses \~0.5 s to
compile them, then it downloads **all 15 fonts of the manifest (1.14 MB, uncompressed) before the
first frame**, then the app blocks on the theme call, then it fetches a 421 KB background and a
106 KB flag. Measured: login form fully painted at **3.7 s** cold on a fast link, **8.6 s** on a
simulated 8 Mbit/s line, **1.3 s** warm. Steps 6–8 take the font phase from \~0.7 s to \~0.2 s and
the cold first paint to \~3.0 s / \~6.7 s; the bundle itself is the remaining cost (§6).

***

## Table of contents

0. [TL;DR](#0-tldr)
1. [What happens today (traced and measured)](#1-what-happens-today-traced-and-measured)
2. [Target sequence](#2-target-sequence)
3. [The plan, ranked by gain per effort](#3-the-plan-ranked-by-gain-per-effort)
4. [Rollout order and compatibility](#4-rollout-order-and-compatibility)
5. [How to measure before / after](#5-how-to-measure-before--after)
6. [Deliberately not in this plan](#6-deliberately-not-in-this-plan)
7. [Files touched](#7-files-touched)
8. [Optional task — student cold load: the four ideas, evaluated](#8-optional-task--student-cold-load-the-four-ideas-evaluated)

***

## 1. What happens today (traced and measured)

### 1.1 The sequence, file by file

```text theme={null}
login_screen.dart            ElevatedButton / onFieldSubmitted
  └─ LoginProvider.loginUser()                         login_provider.dart
       ├─ POST /auth/login                              (Turnstile token when the widget produced one)
       ├─ SecureStorage.saveToken()  →  TokenProvider.setToken()
       └─ AppService.authUserAndRole()                  app_service.dart  ← the SAME method as the F5 boot
            ├─ Future.wait([ initAppLanguage(), handleCompanyTheme() ])
            │     └─ GET /platform/theme?company_id=1   (already fetched at boot, before the form was shown)
            ├─ helper.isMobileDevice(DeviceInfoPlugin)  (platform channel; already known from boot)
            ├─ GET /auth/user                           slim payload (Update 125), ≤ 30 SQL queries
            └─ handleUserRole() → handleStudentRole()
                  ├─ fires GET /dashboard/user/{id}/progress   (statistics, for the activity chart)
                  ├─ fan-out into 7 providers (cards, exams, lessons, certificates, cart badge, bell)
                  └─ await statistics   ← the spinner waits for this too
       notifyListeners()  →  GoRouter refreshListenable  →  redirectionHandler()  →  initHomeLocation
  isLoading = false  (button spinner stops only here)

student_statefulshell.dart   Selector5 → initAllTrainingWithProgress / initializeValidatedLessons
overview_screen.dart         Selector4 → stat cards, Continue learning, GetStarted, activity chart
leaderboard_card_widget.dart postFrame  →  GET /progress/leaderboard?limit=10   (after first paint, OK)
student_appbar_widget.dart   first login only: language dialog (400 ms delay) + nav tour (by design)
```

Two things to notice: `loginUser()` reuses `authUserAndRole()`, which was written for the cold boot
and therefore re-does the boot work (theme, device, language); and every one of the four requests is
awaited **in series**, including the statistics call whose only consumer is a chart that already
has an empty state.

### 1.2 Every HTTP request between the click and the dashboard

The Dio client sets `Content-Type: application/json` on **every** request (GETs included) and adds
`Authorization: Bearer …` once a token exists. Both headers are "non-simple" for CORS, so the browser
sends an `OPTIONS` preflight before each call. The backend answers preflights with
`access-control-max-age: 0` (`config/cors.php`), so **nothing is ever cached** and the preflight is
paid again on every single request.

| # | Request | Preflight | Awaited by the spinner | Needed at login? |
| :- | :- | :- | :- | :- |
| 1 | `OPTIONS /auth/login` | — | yes | no (step 1) |
| 2 | `POST /auth/login` | after 1 | yes | yes. Server work: 1 `SELECT users`, bcrypt check (12 rounds, \~250 ms CPU), `INSERT personal_access_tokens`; in production also a **serial** HTTPS call to `challenges.cloudflare.com/turnstile/v0/siteverify` |
| 3 | `OPTIONS /platform/theme` | — | yes | no (step 1) |
| 4 | `GET /platform/theme?company_id=1` | after 3 | yes | **no**: the login form already rendered with it (step 2) |
| 5 | `OPTIONS /auth/user` | — | yes | no (step 1) |
| 6 | `GET /auth/user` | after 5 | yes | yes, but it can ride on #2 (step 4) |
| 7 | `OPTIONS /dashboard/user/{id}/progress` | — | yes | no (step 1) |
| 8 | `GET /dashboard/user/{id}/progress` | after 7 | **yes** (`await statistics`) | no: chart data, can arrive after paint (step 3) |
| 9 | `OPTIONS` + `GET /progress/leaderboard` | — | no (post-frame) | fine as is |
| 10 | thumbnails / avatars (`CachedNetworkImage`) | — | no | fine as is |

Requests 1–8 are strictly serial (each starts when the previous one returned): the login path is
8 round trips deep before a single dashboard widget is built.

**Server topology (Plesk, `backend-test.theteam.com.tn`, checked 19/10/2026).** nginx runs as a
reverse proxy in front of Apache, which runs PHP. The only custom nginx directive on the vhost is
the `/storage/` CORS block from the CanvasKit image fix; everything under `/api/` is proxied to
Apache. So each of the 8 trips above goes browser → nginx → Apache → Laravel and back, **including
the four empty preflights**.

### 1.3 Measured cost of one round trip (19/10/2026)

`curl` from the dev machine against `https://backend-test.theteam.com.tn/api`, 5 runs each, a fresh
TLS connection every time (the browser keeps one HTTP/2 connection, so subtract the TLS column to
get what the app pays per request). The server answers over HTTP/2 with Brotli.

| Request | Median total | TLS handshake | ≈ server + RTT per request |
| :- | -: | -: | -: |
| `GET /platform/theme?company_id=1` (171 bytes) | 273 ms | 92 ms | **\~180 ms** |
| `OPTIONS /auth/login` (preflight, 204, empty) | 248 ms | 100 ms | **\~150 ms** |
| `OPTIONS /auth/user` (preflight, 204, empty) | 236 ms | 105 ms | **\~130 ms** |
| `POST /auth/login`, unknown e-mail (404: boot + 1 query, no bcrypt) | 268 ms | 98 ms | **\~170 ms** |

Read: an **empty** preflight costs almost as much as a real endpoint. It crosses nginx and Apache
and then Laravel boots fully just to let the global `HandleCors` middleware return a 204.
\~130–150 ms for "two hops + boot + nothing" is the budget steps 1 and 5 go after (nginx can answer a
preflight in \~1 ms; the TCP column above, \~45 ms, is the floor for anything from this machine).

Waterfall estimate for a student login on web, warm HTTP/2 connection, `backend-test`:

```text theme={null}
 #1 OPTIONS login        150
 #2 POST login           170 + bcrypt 250 (+ Turnstile 100–300 in prod)   =  420–720
 #3 OPTIONS theme        150
 #4 GET theme            180
 #5 OPTIONS auth/user    130
 #6 GET auth/user        170 + ~28 queries ≈ 60–150                        =  230–320
 #7 OPTIONS progress     150
 #8 GET progress         180 + 4 grouped queries                           ≈  200
                                                                            ─────────────
 before the dashboard route is built                                        ≈ 1 610–2 070 ms
 + shell / dashboard build, then leaderboard (2 more trips, after paint)
```

This matches the "it spins for about two seconds" feeling even though the payload is now tiny: the
time is in **trips**, not bytes.

### 1.4 Small things that are fine (don't spend time on them)

* `SecureStorage.saveToken()` on web (WebCrypto + `localStorage`): a few ms.
* `ThemeProvider.setPrimaryColor()` → two `SharedPreferences` writes: a few ms.
* `User.fromJson` + the student fan-out on a \~6–20 KB payload: a few ms on the UI thread.
* The dashboard `Selector4` / shell `Selector5` recompute progress on every build; with card-level
  `progress` from the backend this is a loop over 2–10 items, negligible.
* The first-login language dialog + nav tour intentionally delay the dashboard by 400 ms **once
  per user**; not a performance bug.

### 1.5 The app's first load, before the login form (measured 19/10/2026)

Captured with a headless Chrome driven over the DevTools protocol against the deployed test site
(`https://elearning-test.theteam.com.tn/`, the build of 19/10/2026), on-the-wire (compressed)
sizes, three runs: **cold** (empty cache, fast link from the dev machine), **warm** (second load,
cache on) and **cold at a simulated 8 Mbit/s down / 60 ms RTT** home line.

| Phase (cold, fast link) | Starts | Ends | What is downloaded | On the wire |
| :- | -: | -: | :- | -: |
| HTML, `loader.css`, `flutter.js`, Cropper.js from cdnjs | 0 | 0.4 s | 5 small files | 27 KB |
| Dart bundle + CanvasKit, in parallel | 0.36 s | 1.41 s | `main.dart.js` (12.4 MB raw, Brotli), `canvaskit.wasm` + `.js` from gstatic | 3 301 KB + 1 638 KB |
| **Silence**: JS compile, wasm instantiate, engine init | 1.41 s | 1.94 s | nothing | 0 |
| `FontManifest.json`, then **15 fonts in parallel** | 1.94 s | 2.65 s | 6 × Poppins (917 KB), `RobotoMono` from the PDF viewer (85 KB), MaterialIcons (44 KB), 3 × Font Awesome (23 KB), Cupertino + 2 Syncfusion icon fonts (7 KB), **Roboto from gstatic (62 KB)** | 1 138 KB, **none compressed** |
| `runApp` → `authUserAndRole()` → theme | 2.70 s | 3.24 s | `OPTIONS` + `GET /platform/theme` (preflight alone: 376 ms) | 1 KB |
| **Login form painted** (approx.) | | **≈ 3.25 s** | `AssetManifest.bin.json` | 22 KB |
| Login page images | 3.45 s | 3.71 s | `4_auth_bg_blue.jpg` from the backend storage (**5120 × 2880 px**, 412 KB), `english.png` flag (**979 × 512 px**, 104 KB), Turnstile script | 547 KB |
| **Fully painted** | | **≈ 3.7 s** | 34 requests | **6 690 KB** |

| Run | Dart + CanvasKit done | Fonts done | Login form painted | Fully painted |
| :- | -: | -: | -: | -: |
| Cold, fast link | 1.41 s | 2.65 s (0.71 s for the font phase) | ≈ 3.25 s | ≈ 3.7 s |
| Cold, 8 Mbit/s | 5.44 s | 7.22 s (1.26 s) | ≈ 7.7 s | ≈ 8.6 s |
| Warm (everything cached) | ≈ 0.6 s (compile only) | ≈ 0.65 s | ≈ 1.15 s (theme call) | ≈ 1.3 s |

Why the fonts are like this (engine source checked in `flutter/engine/src/flutter/lib/web_ui`):

* **Flutter web downloads every font listed in `FontManifest.json` before the first frame.**
  `initializeEngineServices` awaits `Future.wait([initializeRenderer, _downloadAssetFonts])`, and
  `loadAssetFonts` fetches all families of the manifest in parallel (`canvaskit/fonts.dart`). There
  is no lazy loading per page: the login page pays for every font the whole app declares, and so
  does the admin dashboard. The manifest is the app's `pubspec.yaml` `fonts:` **plus every font
  declared by a dependency** (`syncfusion_flutter_pdfviewer` adds RobotoMono, `font_awesome_flutter`
  three files, `syncfusion_flutter_datagrid` two, `cupertino_icons` one).
* **Poppins is 6 × \~153 KB because the files carry Devanagari.** `Poppins-Regular.ttf` has 1 059
  glyphs for 471 code points: 308 Latin, 94 Devanagari, 69 other; the Devanagari conjunct glyphs
  are most of the file. A Latin + Latin-Extended + punctuation subset of the same file is **25 KB**
  (measured with `pyftsubset`, kerning kept), 14 KB once Brotli-compressed. Arabic is not in
  Poppins today either (the engine already falls back to Noto Sans Arabic from gstatic for `ar`).
* **The frontend vhost serves `font/ttf` uncompressed** (`content-encoding` absent on every font,
  while `.js` / `.json` arrive as Brotli): the nginx `brotli_types` / `gzip_types` list of the
  Plesk vhost does not include font types. Brotli alone would take Poppins-Regular from 158 KB to
  63 KB, MaterialIcons from 44 to 21 KB, RobotoMono from 87 to 48 KB. Fonts also carry no
  `cache-control` header (the warm run still served them from the heuristic cache).
* **Roboto (62 KB) comes from gstatic on every cold load** because the engine always downloads its
  default fallback unless the manifest declares a `Roboto` family (`loadAssetFonts`, `loadedRoboto`).
* **Any emoji on screen triggers a 709 KB download** (`Noto Color Emoji` subset 0 from gstatic,
  `font_fallback_data.dart`). The staff first-login tour strings carry `👋` / `👉`
  (`tourWelcomeTitle`, `tourClickNextHint`), so the **admin dashboard's first paint after a first
  login** pulls 709 KB that the whole dashboard does not otherwise need; the AI-assistant welcome
  (`aiAssistantWelcome`) does the same in the course player.
* **Two more things on the boot path are not fonts but sit in the same waterfall**: on web
  `app.dart` renders `SizedBox.shrink()` while `authResult == null`, so after the HTML loader is
  removed the user sees a **white page for the whole theme round trip** (0.55 s cold, 0.45 s warm;
  the phone shows a spinner); and the login page then downloads a 5120 × 2880 JPEG for a panel of
  \~800 px and a 979 × 512 PNG for a 24 px flag.

**Admin dashboard, first page.** Same boot, same fonts (none are page-specific). After login,
`handleAdminRole()` additionally awaits `GET /dashboard_admin` (+ its preflight) **before**
navigating, and the first interactive login fires the usage-guide tour with the emoji strings
above. Steps 1–5 apply to it unchanged; step 6c covers the emoji.

***

## 2. Target sequence

```text theme={null}
login_screen.dart  →  LoginProvider.loginUser()
  ├─ OPTIONS /auth/login          only the FIRST time in 2 h (preflight cached, step 1)
  ├─ POST /auth/login             → { token, two_factor_enabled, user: <same shape as GET /auth/user> }
  │                                  server: Turnstile verify overlapped with bcrypt (step 5)
  ├─ saveToken / setToken
  └─ AppService.applyAuthUser(user)   role switch + fan-out, NO theme / device / language work (step 2)
       └─ fires GET /dashboard/user/{id}/progress, NOT awaited (step 3)
  notifyListeners() → redirect → dashboard builds from the providers (all data already there)

after first paint, in parallel:  statistics (chart), leaderboard, images
```

Estimated: 170 (trip) + 250 (bcrypt) + 60–150 (payload queries) ≈ **480–570 ms** on `backend-test`
for a repeat login, +150 ms for the first login of the session (one preflight), +0 ms for Turnstile
in production once it overlaps bcrypt. Every dashboard widget that reads the login payload paints in
the first frame; the activity chart and the leaderboard fill in within the next \~400 ms.

***

## 3. The plan, ranked by gain per effort

**1. Step 1 — Preflights: cache them (1 line) and answer them in nginx (1 directive block)**

**1a. `config/cors.php` has `'max_age' => 0`.** Set it to **7200** (Chrome caps the preflight
cache at 2 h; Firefox at 24 h; `supports_credentials => true` is compatible). The browser then
reuses the preflight result per origin + URL + headers, so the login path drops from 8 trips to
5 right away, and every later API call drops one trip too.

```php theme={null}
// config/cors.php
'max_age' => 7200,
```

**1b. Let nginx answer the `OPTIONS` itself (Plesk → Websites & Domains → the backend domain →
Apache & nginx Settings → Additional nginx directives).** Today the vhost has only the
`/storage/` block; add the preflight block next to it. An `OPTIONS` then never reaches Apache
or PHP: \~130–150 ms becomes \~1 ms of nginx time plus the network. The remaining preflight per
endpoint per 2 h (the one 1a cannot cache) becomes free, and so does every preflight of an
older app build or any other client.

```nginx theme={null}
# --- existing block, keep as is (CanvasKit images, Update 19) ---
location ^~ /storage/ {
    add_header Access-Control-Allow-Origin "*" always;
    try_files $uri =404;
}

# --- CORS preflights answered by nginx: no Apache hop, no Laravel boot ---
# `if` in server context cannot carry add_header, so route the request to a
# named internal location that can. Nothing else changes for non-OPTIONS requests
# (Plesk's own proxy location to Apache keeps handling them).
if ($request_method = OPTIONS) {
    rewrite ^ /__cors_preflight last;
}
location = /__cors_preflight {
    internal;
    add_header Access-Control-Allow-Origin  $http_origin always;   # same as Laravel: origin reflected
    add_header Access-Control-Allow-Credentials "true" always;
    add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
    add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept, Accept-Language, X-Requested-With" always;
    add_header Access-Control-Max-Age 7200 always;
    add_header Vary "Origin, Access-Control-Request-Method, Access-Control-Request-Headers" always;
    add_header Content-Length 0 always;
    return 204;
}
```

Notes for 1b:

* It mirrors what `config/cors.php` does today (`allowed_origins => ['*']` with credentials,
  so the origin is reflected). If the Laravel config is ever narrowed to a list of origins, the
  nginx block must get the same list (`if ($http_origin ~* ^https://(elearning(-test|-dev)?\.theteam\.com\.tn)$)`),
  otherwise nginx would keep accepting origins Laravel refuses.
* Keep 1a as well: it is what the browser caches, and it is the only thing in effect behind
  `php artisan serve` locally and on any vhost where the block was not pasted.
* Plesk validates the nginx config on save; if it refuses the block, the usual cause is a
  vhost in "nginx serves PHP" mode where `rewrite … last` interacts with Plesk's PHP location.
  Then use `location ^~ /api/ { if ($request_method = OPTIONS) { … return 204; } … }` and
  copy Plesk's `proxy_pass` / `proxy_set_header` lines from the generated
  `/var/www/vhosts/system/<domain>/conf/nginx.conf` into it.
* Apply on `backend-test` first, measure with the §5 commands (the `OPTIONS` row should fall
  from \~250 ms to \~60–90 ms total from the dev machine), then `dev` and `prod`.

**1c.** While there: `app/Http/Kernel.php` registers **two** CORS middlewares in the global stack,
`\Illuminate\Http\Middleware\HandleCors` (Laravel 10's own) and `\Fruitcake\Cors\HandleCors`
(the pre-Laravel-9 package). Keep only Illuminate's; both read the same config, so behavior is
unchanged and one pass of header work per request disappears. Check `composer.json` and drop
`fruitcake/laravel-cors` if nothing else uses it.

Caveats: the preflight cache is keyed per URL, so the first call to **each** endpoint in a 2 h
window still pays one preflight (that is the one 1b makes almost free). A preflight is still
required (the `Authorization` header forces it); removing `Content-Type` from GETs would not
avoid it, so don't bother.

Verify: `curl -sD - -o /dev/null -X OPTIONS -H "Origin: http://localhost:5000" -H "Access-Control-Request-Method: GET" -H "Access-Control-Request-Headers: authorization,content-type" https://backend-test.theteam.com.tn/api/auth/user`
must show `access-control-max-age: 7200`; after 1b the same response has no `x-powered-by: PHP`
header (it never reached PHP). The Chrome Network tab then shows one `OPTIONS` per endpoint
instead of one per call, each a few ms long.

**2. Step 2 — Don't re-run the boot work after login (frontend only)**

Split `AppService.authUserAndRole()` into a one-time bootstrap and a reusable "apply this user"
method. `loginUser()` / `loginUserWith2FA()` call only the second one.

```dart theme={null}
// app_service.dart (sketch)
bool _bootstrapped = false;

/// Language, company theme, device class, onboarding flag: once per app lifetime.
Future<void> bootstrapOnce(BuildContext context) async {
  if (_bootstrapped) return;
  _bootstrapped = true;
  await Future.wait([initAppLanguage(), handleCompanyTheme(context: context)]);
  isMobileDevice = await helper.isMobileDevice(DeviceInfoPlugin());
  initOnboarding(isMobileDevice);
}

/// Cold boot / F5 (app.dart) and sign-out: unchanged behavior.
Future<bool> authUserAndRole(BuildContext context) async {
  await bootstrapOnce(context);
  final token = context.read<TokenProvider>().token;
  if (token == null) { authUser = null; notifyListeners(); return true; }
  try {
    final user = await apiClient.authUser();
    if (context.mounted) await applyAuthUser(context, user);
  } catch (error, stacktrace) { debugPrint('$error'); debugPrint('$stacktrace'); }
  return true;
}

/// Everything that depends on WHO is logged in. Also the target of step 4.
Future<void> applyAuthUser(BuildContext context, User? user) async {
  authUser = user;
  context.read<UserProvider>().updateUser(user);
  // restricted roles on mobile: the existing block, unchanged
  // ...
  await handleUserRole(context, user!, isMobileDevice);
  notifyListeners(); // → GoRouter refreshListenable → redirectionHandler → initHomeLocation
}
```

`signOutUser()` / `deleteMyAccount()` keep calling `authUserAndRole()` (token is null → login
route). The theme refresh after an admin edits the platform colors already goes through
`PlatformConfigProvider` directly, so it is not affected.

Removes requests #3 and #4 of §1.2 and the `DeviceInfo` platform call from the login path.

**3. Step 3 — Take the statistics call off the critical path (frontend only)**

In `handleStudentRole()` the statistics future is started early (good) but **awaited at the
end**, so the spinner waits \~200 ms for chart data. Stop awaiting it, and make its arrival
repaint the chart:

* `StudentStatisticProvider.getStudentStatistic()` sets `studentData` **without**
  `notifyListeners()`; today that is harmless only because the call is awaited before the
  dashboard exists. Add `isLoading` + `notifyListeners()` on success and failure.
* `LearningActivityCard` already does `context.watch<StudentStatisticProvider>()` and the phone
  dashboard uses a `Consumer`, so they repaint on their own once the provider notifies. Show the
  card's loading state while `isLoading` so it doesn't flash "Nothing to chart yet" first.
* `handleStudentRole()`: replace `await statistics` with `unawaited(statistics)` (the
  `catchError` inside the provider already swallows failures). `refreshAuthUser()` (phone
  pull-to-refresh) may keep awaiting so the refresh indicator spins until the chart is fresh.

Removes requests #7 and #8 from the critical path (they still happen, after the first frame).

**4. Step 4 — `POST /auth/login` returns the boot payload (backend → frontend → docs)**

The login response is the only request we cannot avoid, and the server already has the user in
hand, so it should return what `GET /auth/user` would have returned next. Same builder, same
shape, same Dart model.

**Backend** (`AuthController::login`, after the 2FA block):

```php theme={null}
$token = $user->createToken('Personal Access Token')->plainTextToken;

// Same relations auth_user() loads, same builder: identical shape to GET /auth/user.
$user->load(['image', 'company.companyImage', 'role.permissions']);

return response()->json([
    'error'              => false,
    'message'            => trans('messages.login_success'),
    'token'              => $token,
    'two_factor_enabled' => (bool) $user->two_factor_enabled,
    'user'               => app(AuthUserPayloadService::class)->build($user),
], 200);
```

* The 2FA flow is unchanged: the first `POST` still answers `402`; the second `POST` (with
  `2fa_code` / `2fa_recovery_code`) returns the `user` block too.
* No `?legacy=1` on login: only new app builds read `user`; old builds ignore the key and keep
  calling `GET /auth/user`. The 28-query budget of `AuthUserPayloadTest` applies here too.
* Test: in `tests/Feature/AuthUserPayloadTest.php` (or a new `LoginPayloadTest`), log in with
  the seeded student and assert `response['user']` equals `GET /auth/user` for the same user
  (minus volatile fields), and that a staff login carries no student blocks.

**Frontend**

* `LoginResponse`: add `final User? user;` (`@JsonKey(name: 'user')`), regenerate with
  `dart run build_runner build -d`.

* `LoginProvider.loginUser()` / `loginUserWith2FA()`:

  ```dart theme={null}
  final response = await apiClient.login(requestBody: loginData, acceptLanguage: acceptLanguage);
  await secureStorage.saveToken(response.token);
  if (!context.mounted) return;
  context.read<TokenProvider>().setToken(response.token);
  if (response.user != null) {
    await appService.applyAuthUser(context, response.user);   // one round trip, done
  } else {
    await appService.authUserAndRole(context);                 // older backend: old path
  }
  ```

* `applyAuthUser()` (step 2) already contains the mobile restricted-role rule, so an
  instructor logging in on the phone is still logged straight back out.
  *04/11/2026:* no longer true for instructors: since Update 223 they stay signed in on
  phones and get a small instructor app (`restrictedRoles = [3, 4, 5]`, see the
  [Changelog](https://theteam-13fc7f49.mintlify.site/changelog/changelog.md)); content managers, admins and super-admins are still
  signed out there.

**Docs**: `api-reference/auth.mdx` §4 success response gains the `user` key with a pointer to
[auth.mdx §9](https://theteam-13fc7f49.mintlify.site/api-reference/auth.md) for its shape; add a line to
[student boot payload](https://theteam-13fc7f49.mintlify.site/api-reference/student-boot-payload.md) that the same payload is also
returned by login since this change.

Removes requests #5 and #6: the login path is now **one** request (plus a cached preflight).

**5. Step 5 — Server hot-path hygiene (each one small, all measurable)**

These shave the \~130–180 ms "boot + nothing" cost of §1.3 and the login handler itself.

1. **PHP side on the Plesk vhosts (test / dev / prod).** Make sure the deploy runs
   `php artisan optimize` (`config:cache` + `route:cache` + `event:cache`) and that OPcache is on
   with `opcache.validate_timestamps=0` in production (`php -i | grep opcache` on the box). An
   empty `OPTIONS` at 130–150 ms is the usual signature of an un-cached Laravel boot. Expect
   50–80 ms per request back.

   Also on Plesk: the vhost currently runs PHP **behind Apache** (nginx proxies to Apache,
   which runs PHP and honors `public/.htaccess`). Plesk can instead run the domain as
   "PHP-FPM served by nginx", which removes the Apache hop from every API request (typically
   another 10–30 ms per request, more under load). It is a server-config decision with two
   conditions: the document root must be Laravel's `public/` and `.htaccess` is ignored, so the
   front-controller rule moves into the same "Additional nginx directives" box
   (`location / { try_files $uri $uri/ /index.php?$query_string; }`), and the preflight block of
   step 1b must still validate (see its last note). Try it on `backend-test` only, with the §5
   numbers before / after; switching the handler back in Plesk is the rollback.
2. **Overlap the Turnstile verification with bcrypt (production only).** Today the handler
   awaits Cloudflare, *then* looks the user up, *then* runs `Hash::check` (\~250 ms). Start the
   HTTP call asynchronously, do the lookup + hash check while it is in flight, then **check the
   captcha result first** and only then return the credential outcome (so an unverified request
   still learns nothing about the account):

   ```php theme={null}
   $pending = $needsCaptcha
       ? Http::asForm()->async()->post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
             'secret' => config('services.turnstile.secret'), 'response' => $request->input('turnstileToken'),
         ])
       : null;

   $user = User::where('email', $request->email)->first();
   $passwordOk = $user && Hash::check($request->password, $user->password);

   if ($pending && empty($pending->wait()->json()['success'])) {
       return response()->json(['error' => true, 'message' => 'Captcha verification failed. Please try again or reload page.'], 422);
   }
   // then the existing 404 / 403 / blocked / 401 / 2FA branches, using $passwordOk
   ```

   Saves `min(bcrypt, Cloudflare RTT)` ≈ 100–250 ms per production login. Read the secret from
   `config('services.turnstile.secret')` (fed by `CLOUDFLARE_TURNSTILE_SECRET_KEY`, already in
   `.env.example`) instead of a literal in the controller.
3. **`UserPolicy::view`** runs `$user->permissions()->pluck('id')` (one query) before testing
   `$user->id === $model->id`. Put the self check first: one query less on `GET /auth/user`
   and on the login payload.
4. **`throttle:api` with `CACHE_DRIVER=file`** does a locked file read + write per request. If
   the vhost has APCu or Redis, point `CACHE_DRIVER` at it (also speeds up every other request).
   Minor, but free.
5. **bcrypt rounds**: `BCRYPT_ROUNDS` defaults to 12 (\~250 ms per login on this CPU). 10 rounds
   would be \~60 ms. This is a **security trade-off, not a bug**, so it is listed and not
   recommended; if the user decides to change it, Laravel re-hashes on the next login
   automatically (`Hash::needsRehash`) only if the login code calls it, which it does not today.
6. **Sanctum stateful domains**: if a production `.env` lists the frontend domain in
   `SANCTUM_STATEFUL_DOMAINS`, every API request also starts a file session + CSRF check. The
   app authenticates with bearer tokens only, so that variable should stay empty. Nothing to
   change if it already is.

**6. Step 6 — Fonts: 1 138 KB before the first frame → \~190 KB (frontend + Plesk)**

The engine will always download the whole manifest before painting (§1.5), so the lever is
what the manifest contains and how it is served.

**6a. Subset Poppins to Latin (915 KB → 152 KB, same 6 files, same names).** One-off tool run,
output committed in place of the current files; no new package in the app:

```bash theme={null}
pip3 install --target /tmp/pyft fonttools brotli      # tool only, not a project dependency
cd LMS_Frontend/assets/fonts/poppins
for w in Light Regular Medium SemiBold Bold ExtraBold; do
  PYTHONPATH=/tmp/pyft python3 -m fontTools.subset "Poppins-$w.ttf" \
    --unicodes="U+0000-024F,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+20A0-20CF,U+2122,U+2190-2199,U+2212,U+2215,U+FEFF,U+FFFD" \
    --layout-features='*' --name-IDs='*' --notdef-outline --output-file="Poppins-$w.ttf"
done
```

Measured on each weight: 149–156 KB → 25 KB. The range keeps every Latin-1 and
Latin-Extended-A/B letter (French, Spanish, Turkish…), punctuation, currency signs
(`U+20A0-20CF`, the TND dinar has no glyph anyway) and arrows. Arabic text keeps using the
engine's Noto fallback as it does today. Check the login page, a quiz review and an `ar` page
before and after with `test/student_web_responsive_test.dart` + the headless screenshots.

**6b. Compress fonts on the frontend vhost (152 KB → 82 KB for Poppins; ×2 on the icon
fonts).** Plesk → the frontend domain (`elearning-test`, then `dev`, `prod`) → Apache & nginx
Settings → Additional nginx directives:

```nginx theme={null}
# Fonts arrive as font/ttf and font/otf today, uncompressed; .js/.json are already Brotli.
brotli_types font/ttf font/otf application/font-sfnt application/x-font-ttf application/octet-stream;
gzip_types   font/ttf font/otf application/font-sfnt application/x-font-ttf application/octet-stream;
# Optional: the font files only change on a deploy that also changes their bytes.
location ~* ^/assets/.*\.(ttf|otf)$ { expires 7d; add_header Cache-Control "public"; }
```

If the vhost has no Brotli module, the `gzip_types` line alone gives 158 → 71 KB on a
Poppins file. Verify with `curl -sI -H "Accept-Encoding: br, gzip" https://elearning-test.theteam.com.tn/assets/assets/fonts/poppins/Poppins-Regular.ttf`
(expect `content-encoding: br` or `gzip`).

**6c. No emoji in strings that render at boot.** Replace `👋` / `👉` in `tourWelcomeTitle`,
`tourClickNextHint` (and `aiAssistantWelcome`) in the three translation files by an icon in the
widget (`Icons.waving_hand_outlined` / `HeroIcons.arrowRight`), or drop them. Saves the
709 KB `Noto Color Emoji` download on the staff first login and in the course player.

**6d. Optional, small.** Declare a tiny local `Roboto` family (a 2–3 KB Latin subset of
`Roboto-Regular.ttf`) in `pubspec.yaml`: the engine then skips the 62 KB gstatic fetch and the
extra origin (DNS + TLS) on every cold load. Keeping only 3 Poppins weights in `fonts:` and
loading the other 3 after the first frame with `FontLoader` is possible too, but after 6a each
weight is 14 KB compressed, so it is not worth the reflow risk; listed, not recommended.

**Not fixable from the app:** `RobotoMono` (85 KB, 48 KB compressed) is declared by
`syncfusion_flutter_pdfviewer` and merged into the manifest by the build; only dropping the
package removes it.

After 6a–6c, the font phase is \~9 requests / \~190 KB compressed instead of 15 / 1 138 KB:
≈ 0.15–0.2 s instead of 0.71 s on a fast link, ≈ 0.3 s instead of 1.26 s at 8 Mbit/s.

**7. Step 7 — Login page images: 520 KB → \~95 KB (admin upload + 3 asset files)**

* **Auth background** (`platform_config` → auth page image, served from the backend
  `/storage/`): the uploaded file is **5120 × 2880 px, 412 KB** for a panel that is at most
  \~900 px wide. Re-export at 1600 × 900, JPEG q80 or WebP (≈ 80–100 KB), re-upload in the
  platform configuration. Optional backend follow-up: resize on upload in
  `PlatformThemeController::updateThemeSettings` so the next admin cannot reintroduce it.
* **Language flags** `assets/images/english.png` (979 × 512, 104 KB), `arabic.png` (767 × 512,
  62 KB), `french.png` (768 × 512, 15 KB) are drawn at \~24 px. Resize to 64 × 64 (≈ 2–3 KB
  each). Only the current language's flag loads on the login page, but all three ship.
* Both load **after** the form, so they do not move "login form painted", only "fully
  painted" (−0.4 s cold fast link, −0.9 s at 8 Mbit/s) and the cold-boot bytes.

**8. Step 8 — Boot paint: login from the cached theme, no white page (frontend)**

* **Stale-while-revalidate for the theme.** `handleCompanyTheme()` already persists the two
  colors in `SharedPreferences`; also persist `auth_page_image`, `platform_logo`,
  `remove_auth_image` and `company_name`, and on boot **resolve immediately from the cache**
  when it exists, then run `GET /platform/theme` in the background and `notifyListeners()`
  only if something changed. The login form then paints right after the fonts (≈ 2.7 s cold
  instead of 3.25 s; ≈ 0.7 s warm instead of 1.15 s). The very first visit still waits for
  the call. Step 1 cuts that call's preflight on every vhost that got the nginx block.
* **No `SizedBox.shrink()` on web while `authResult == null`** (`app.dart`): show the same
  `LoadingProgressWidget` the phone shows, or keep the HTML `.loader` until the first route
  (remove it from `index.html` in a post-frame callback of the first page). Today the user
  sees white between the engine's first frame and the theme response.
* `authUserAndRole()` on boot is also what step 2 splits: `bootstrapOnce()` is the only place
  that runs the theme + device + language work.

**`web/index.html` gotcha (hit on 19/10/2026, Update 127 "PWA removed").** The page boots with
its own script (the `flutter_js` and `flutter_build_config` tokens followed by
`_flutter.loader.load()` without `serviceWorkerSettings`). A comment in that block mentioned the
default bootstrap token by name, and `flutter build web` substitutes **every** double-brace token
it finds, comments included, with the full multi-line bootstrap script. Everything after its first
newline fell outside the `//` comment, the script block failed to parse
(`SyntaxError: Unexpected identifier 'more'`), `_flutter.loader.load()` never ran and the deployed
test site showed the HTML spinner forever. Fix: no token text anywhere in `index.html` except where
it must be substituted. Check a change to that file before deploying by running the substitution
locally and `node --check` on every inline script (the JSON-LD block is data, not JavaScript).

### 3.1 Optional: paint the dashboard before the login response lands

After step 4 there is a single request left, so the usual "navigate now, fill in later" trick buys
very little (\~bcrypt + payload ≈ 300–400 ms of skeleton). It is listed here so the trade-off is on
record and **not** recommended: the dashboard would briefly show empty stat cards and an empty
"Continue learning" list, which is exactly the pop-in the user wants to avoid. Keep the button
spinner; make the first frame the complete one.

***

## 4. Rollout order and compatibility

1. **Step 1** (CORS `max_age` + nginx preflight block): no app change. 1a is a backend deploy;
   1b is pasted into the Plesk nginx directives of `backend-test` first, measured, then `dev` and
   `prod`. Preflight responses are cached per browser, so there is nothing to invalidate; if it ever
   has to be rolled back (remove the block, or `max_age` back to 0), old cache entries expire within
   2 h.
2. **Steps 2 + 3** (frontend): work with any backend, web and mobile. Ship as one Update in
   `major_code_updates.md` with the before/after numbers of §5.
3. **Step 4**: backend **first** (the extra `user` key is ignored by current app builds), then the
   frontend (which falls back to `GET /auth/user` when `user` is missing, so a stale backend keeps
   working). The docs page follows the backend commit.
4. **Step 5** items are independent; do 5.1 (opcache / `optimize`) first, it is the biggest and
   needs no code.

Nothing here changes the shape of `GET /auth/user`, the `?legacy=1` flag, the 2FA flow, the mobile
restricted-role rule, or the first-login language dialog + tour.

***

## 5. How to measure before / after

**In the browser (the number that matters).** Chrome DevTools → Network, tick *Preserve log*,
clear, click Login, stop when `progress/leaderboard` appears. Read:

* the count of rows between `auth/login` and the first dashboard image (today 8 before the route,
  target 1–2);
* the *Waterfall* column: the gap from the `POST auth/login` start to the `GET progress/leaderboard`
  start is "click → dashboard built". Record it 3 times, take the median.

Performance panel → *Record* across the click gives the same gap as "first dashboard frame" plus
the Dart build time of the shell (expected ≈ 50–120 ms, unchanged by this plan).

**On the server, without credentials** (the §1.3 commands, repeat after step 1 and step 5.1):

```bash theme={null}
B=https://backend-test.theteam.com.tn/api; O=http://localhost:5000
F='code=%{http_code} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n'
for i in 1 2 3 4 5; do curl -s -o /dev/null -w "$F" -X OPTIONS -H "Origin: $O" \
  -H "Access-Control-Request-Method: GET" -H "Access-Control-Request-Headers: authorization,content-type" "$B/auth/user"; done
for i in 1 2 3 4 5; do curl -s -o /dev/null -w "$F" -H "Origin: $O" -H "Accept: application/json" "$B/platform/theme?company_id=1"; done
```

**With a token** (from the app's Network tab, never commit it): time `GET /auth/user` and
`POST /auth/login` the same way; after step 4 compare the login total against the old
`login + auth/user` sum.

**The app's first load** (§1.5) was captured with a small `puppeteer-core` script over the
DevTools protocol (`Network.enable`, `Network.clearBrowserCache`,
`Network.emulateNetworkConditions` for the 8 Mbit/s run), recording every request's start, end
and `encodedDataLength`, and waiting for `flt-semantics-placeholder` then network idle. Chrome
DevTools → Network with *Disable cache* ticked gives the same waterfall by hand; the numbers to
read are the end of `main.dart.js`, the end of the last font, the `platform/theme` response and
the end of the auth background image.

### 5.1 Before / after, phase by phase

"Before" is what was measured on 19/10/2026 (`backend-test` / `elearning-test`, dev machine, fast
link unless stated). "After" is the estimate once the named steps are in; the last column is for
the real measurement when each step ships (put the same numbers in the Update entry).

| Phase | Before (measured) | After (estimate) | From steps | After (measured) |
| :- | -: | -: | :- | -: |
| **First load, cold, fast link** | | | | |
| Dart bundle + CanvasKit downloaded | 1.41 s | 1.41 s (unchanged, §6) | — | |
| JS compile + engine init (silence) | 0.53 s | 0.53 s (unchanged, §6) | — | |
| Font phase (manifest + all fonts) | 0.71 s, 15 req, 1 138 KB | ≈ 0.15–0.2 s, 9 req, ≈ 190 KB | 6a, 6b, 6c, (6d) | |
| Theme round trip before the form (white page) | 0.55 s | 0 (cached theme), ≈ 0.2 s on the very first visit | 8, 1 | |
| **Login form painted** | **≈ 3.25 s** | **≈ 2.7 s** | 6, 8 | |
| Login images | 0.46 s, 520 KB | ≈ 0.2 s, ≈ 95 KB | 7 | |
| **Fully painted** | **≈ 3.7 s, 34 req, 6.7 MB** | **≈ 2.9–3.0 s, ≈ 27 req, ≈ 5.3 MB** | 6, 7, 8 | |
| **First load, cold, 8 Mbit/s (simulated)** | | | | |
| Dart bundle + CanvasKit downloaded | 5.44 s | 5.44 s | — | |
| Font phase | 1.26 s | ≈ 0.3 s | 6 | |
| Login form painted | ≈ 7.7 s | ≈ 6.3 s | 6, 8 | |
| Fully painted | ≈ 8.6 s | ≈ 6.7 s | 6, 7, 8 | |
| **First load, warm (cache)** | | | | |
| Login form painted | ≈ 1.15 s | ≈ 0.7 s | 8 (compile time remains) | |
| Fully painted | ≈ 1.3 s | ≈ 0.75 s | 8 | |
| **Login → student dashboard (credentials valid)** | | | | |
| Server round trips before the dashboard route | 8 (4 requests + 4 preflights) | 1 (+ 1 preflight per 2 h) | 1, 2, 3, 4 | |
| One preflight (`OPTIONS`), server + RTT | ≈ 130–150 ms | ≈ 10–40 ms, and cached | 1b, 1a | |
| `POST /auth/login` total, student (test; prod adds Turnstile) | ≈ 420–720 ms | ≈ 480–570 ms **including the payload** (prod: Turnstile overlapped) | 4, 5.2 | |
| `GET /platform/theme` + `DeviceInfo` after login | ≈ 0.33 s (+ preflight) | 0 | 2 | |
| `GET /auth/user` as a separate trip | ≈ 0.23–0.32 s (+ preflight) | 0 (rides on the login response) | 4 | |
| `GET /dashboard/user/{id}/progress` awaited | ≈ 0.2 s (+ preflight) | 0 on the critical path (chart fills in after paint) | 3 | |
| **Click → dashboard built** | **≈ 1.7–2.0 s** | **≈ 0.5–0.7 s** | 1–5 | |
| **Login → admin dashboard** | | | | |
| Same path + `GET /dashboard_admin` awaited (+ preflight) | ≈ 1.9–2.4 s | ≈ 0.7–0.9 s (statistics still awaited; make them non-blocking like step 3 if wanted) | 1–5 | |
| Staff first-login tour: `Noto Color Emoji` 709 KB | 0.4–1.5 s extra on the first login, by link | 0 | 6c | |

**Totals, in seconds** (app opened → login form on screen → Login clicked → dashboard built; the
time the user spends typing is not counted; login → dashboard is the §1.3 estimate, so on the
8 Mbit/s line add \~0.1–0.2 s per remaining round trip):

| Scenario | Before: first load → form | Before: click → dashboard | **Before total** | After: first load → form | After: click → dashboard | **After total** | Saved |
| :- | -: | -: | -: | -: | -: | -: | -: |
| Student, cold, fast link | 3.25 s | 1.7–2.0 s | **≈ 5.0–5.3 s** | 2.7 s | 0.5–0.7 s | **≈ 3.2–3.4 s** | ≈ 1.8–1.9 s |
| Student, cold, 8 Mbit/s | 7.7 s | 1.9–2.3 s | **≈ 9.6–10.0 s** | 6.3 s | 0.6–0.9 s | **≈ 6.9–7.2 s** | ≈ 2.7–2.8 s |
| Student, warm (cache) | 1.15 s | 1.7–2.0 s | **≈ 2.9–3.2 s** | 0.7 s | 0.5–0.7 s | **≈ 1.2–1.4 s** | ≈ 1.7–1.8 s |
| Admin, cold, fast link, first login (tour + emoji font) | 3.25 s | 2.3–3.9 s | **≈ 5.6–7.2 s** | 2.7 s | 0.7–0.9 s | **≈ 3.4–3.6 s** | ≈ 2.2–3.6 s |
| Admin, cold, fast link, later logins | 3.25 s | 1.9–2.4 s | **≈ 5.2–5.7 s** | 2.7 s | 0.7–0.9 s | **≈ 3.4–3.6 s** | ≈ 1.8–2.1 s |
| Already logged in, F5 (boot with a token) | 3.25 s + `GET /auth/user` 0.23–0.32 s (+ preflight) | — | **≈ 3.6–3.7 s** | 2.7 s + 0.25–0.3 s | — | **≈ 3.0 s** | ≈ 0.6–0.7 s |

Reading the table: the bundle (3.3 MB) and its compile (0.5 s) stay as they are. That is the
floor of a cold first load without the renderer work that is out of scope. Everything else on
the page, fonts, theme, images, preflights, serial calls, is addressable in small steps.

***

## 6. Deliberately not in this plan

* **Flutter renderer / wasm**: excluded by the user for now. Nothing above depends on it.
* **The Dart bundle (`main.dart.js`, 12.4 MB raw / 3.3 MB Brotli) and its 0.5 s compile.** The
  only app-side lever is code splitting with deferred imports (`import '…' deferred as`): the
  student shell would stop loading the admin / instructor / content-manager / super-admin pages,
  the Syncfusion grids and the editors on first load. It is a routing refactor with its own test
  pass, so it is a separate plan, not a step here. Numbers to decide with: the bundle is 1.05 s of
  the 3.7 s cold load on a fast link and 5.1 s of the 8.6 s at 8 Mbit/s.
* **Caching the login payload in `localStorage` for F5 boots** (the old §7.9 idea): a different
  path (cold boot, not login) and a stale-data risk; revisit only if the F5 path is still slow after
  steps 1 and 5.
* **A service worker / HTTP cache for API JSON**: the payload is per-user and changes with every
  lesson; not worth the invalidation rules.
* **Lowering bcrypt rounds**: listed in 5.5 as a decision, not a recommendation.
* **Removing Turnstile from login**: no; 5.2 hides its cost instead.
* **Skeleton dashboard before the response** (§3.1): would reintroduce pop-in.

***

## 7. Files touched

| Repo | File | Step |
| :- | :- | :- |
| `LMS_Backend` | `config/cors.php` (`max_age`) | 1a |
| Plesk (`backend-test`, then `dev`, `prod`) | Apache & nginx Settings → Additional nginx directives: preflight block next to the `/storage/` block | 1b |
| `LMS_Backend` | `app/Http/Kernel.php` (one `HandleCors`), `composer.json` | 1 |
| `LMS_Backend` | `app/Http/Controllers/AuthController.php` (`login()`: `user` block, async Turnstile, config secret) | 4, 5.2 |
| `LMS_Backend` | `app/Policies/UserPolicy.php` (`view()` order) | 5.3 |
| `LMS_Backend` | `tests/Feature/AuthUserPayloadTest.php` or new `LoginPayloadTest.php` | 4 |
| `LMS_Backend` | deploy script / Plesk PHP settings (`optimize`, OPcache, `CACHE_DRIVER`) | 5.1, 5.4 |
| `LMS_Frontend` | `lib/core/providers/app_service.dart` (`bootstrapOnce`, `applyAuthUser`, `handleStudentRole` no longer awaits statistics) | 2, 3, 4 |
| `LMS_Frontend` | `lib/core/providers/login_provider.dart` (`loginUser`, `loginUserWith2FA`) | 2, 4 |
| `LMS_Frontend` | `lib/core/providers/student_statistic_provider.dart` (`isLoading`, `notifyListeners`) | 3 |
| `LMS_Frontend` | `lib/ui/pages/student_pages/dashboard/widget/learning_activity_card.dart` (loading state) | 3 |
| `LMS_Frontend` | `lib/core/data/response_type/login_response.dart` (+ generated) | 4 |
| `LMS_Frontend` | `assets/fonts/poppins/*.ttf` (Latin subsets, same names), optional `Roboto` subset + `pubspec.yaml` | 6a, 6d |
| Plesk (`elearning-test`, then `dev`, `prod`) | Additional nginx directives: `brotli_types` / `gzip_types` for fonts, optional `expires` | 6b |
| `LMS_Frontend` | `assets/translations/{en,fr,ar}.json` (`tourWelcomeTitle`, `tourClickNextHint`, `aiAssistantWelcome`) + the tour / assistant widgets | 6c |
| `LMS_Frontend` | `assets/images/english.png`, `french.png`, `arabic.png` (64 × 64) | 7 |
| Platform configuration (admin UI) | auth page image re-uploaded at 1600 × 900; optional resize-on-upload in `PlatformThemeController` | 7 |
| `LMS_Frontend` | `lib/core/providers/platform_config_provider.dart` + `app_service.dart` `handleCompanyTheme()` (theme cache), `lib/app.dart` (web spinner), `web/index.html` (loader hand-off) | 8 |
| `LMS_Frontend` | `major_code_updates.md` (Update entry with the §5.1 table) | all |
| `LMS_Documentation` | `api-reference/auth.mdx` §4, `api-reference/student-boot-payload.mdx`, `changelog/*` | 4 |

Related pages: [Auth User Optimize](https://theteam-13fc7f49.mintlify.site/clean-up/auth-user-optimize.md) (the payload work this builds
on), [Auth API §9](https://theteam-13fc7f49.mintlify.site/api-reference/auth.md) (the slim `GET /auth/user` shape),
[Student boot payload](https://theteam-13fc7f49.mintlify.site/api-reference/student-boot-payload.md) (history of the old flow).

***

## 8. Optional task — student cold load: the four ideas, evaluated

Asked on 19/10/2026: *"use fewer fonts on the login / dashboard? skip the company theme endpoint
on the login page and consume it after login? switch the icons? compress the default login
right-side image?"* Each idea is answered against the §1.5 capture and a count of the real call
sites in `lib/`. Short version: three of the four are already most of steps 6–8; what they add on
top is small, and the honest ceiling for a student cold load without touching the bundle is
**≈ 2.65 s to the login form on a fast link** (from 3.25 s today).

**Status: OPTIONAL, not started.** Do steps 6–8 first; the items below are the extra 5–10 % and
the two "don't bother" verdicts, kept here so nobody re-investigates them.

### 8.1 "Use fewer fonts on the login / dashboard" — partly yes, after step 6a

Facts (grep of `FontWeight.w*` on 19/10/2026):

| Where | Weights used |
| :- | :- |
| Login page (`login_screen.dart`, language picker) | 400 ×1, 500 ×5, 600 ×3 |
| Student web dashboard (`overview_screen.dart` + its widgets, app bar, leaderboard) | 400 ×13, 500 ×22, 600 ×39, 700 ×9 |
| Whole app | 300 ×5, 400 ×230, 500 ×633, 600 ×475, 700 ×131, 800 ×5 |

* **Per-page font loading does not exist on Flutter web**: whatever is in the manifest is
  downloaded before the first frame (§1.5). The only "later" mechanism is `FontLoader` for files
  kept out of `fonts:`; at 14 KB per subset weight (after 6a) it is not worth the reflow when the
  late weight arrives. So "fewer fonts for the login page" has to mean **fewer fonts for the
  app**.
* **Drop Light (300) and ExtraBold (800) altogether**: 5 call sites each in the whole app, none
  on the login page or the dashboard. Replace them by 400 / 700 at those 10 sites (Flutter would
  snap to the nearest weight anyway) and delete the two files + their `pubspec.yaml` lines.
  Saves 2 requests and \~28 KB compressed after 6a (\~300 KB today, before the subset).
* **Keep 400 / 500 / 600 / 700**: 1 469 call sites; the dashboard uses all four.
* **Drop `cupertino_icons`**: `CupertinoIcons.` has **0** uses in `lib/`; the package is only
  there from the Flutter template, and it puts `CupertinoIcons.ttf` (3 KB) into the manifest.
  Remove the dependency: 1 request fewer. (`flutter_quill` / Syncfusion do not declare it.)
* **`FontAwesomeBrands`** carries 2 icons (`facebookF`, `instagram`, 1.9 KB file): replacing
  them with two tiny SVG assets removes 1 request. Marginal; do it only if the social links move
  anyway.
* Result on top of step 6: the font phase goes from 9 requests / \~190 KB to **6 requests /
  \~150 KB** (Poppins 400–700, MaterialIcons, FA solid + regular, Syncfusion grid icons,
  RobotoMono, and Roboto unless 6d). ≈ 0.05 s on a fast link, ≈ 0.1 s at 8 Mbit/s. Worth doing
  because it is also less to maintain, not because it is big.

### 8.2 "Ignore the theme endpoint on the login page, consume it after login" — no as stated, yes as "paint first, theme second"

* The login page **is** the branded surface: `GET /platform/theme` gives it the two colors, the
  right-side image (`auth_page_image`), `remove_auth_image`, the logo and the company name. Fetching
  it only after login would show every white-label company the default green login on every
  visit. Not recommended.
* What gives the same speed without losing branding, in this order:
  1. **Cached theme (step 8):** every visit after the first paints the login immediately from
     `SharedPreferences`, the call refreshes it in the background. This already removes the
     0.55 s (cold) / 0.45 s (warm) wait on every return visit.
  2. **First visit: render with the built-in defaults and do not wait either.** `handleCompanyTheme()`
     already falls back to `119B78` / `D48941` and the local `authBackground.jpg`; make
     `authUserAndRole()` resolve the login route **before** the theme call returns and let
     `ThemeProvider.notifyListeners()` recolor the page when it lands (one color flip, \~0.5 s,
     first visit only). The dashboard never sees this: by the time the user has typed a
     password the theme is in.
  3. **Step 1b on the backend vhost** makes that call's preflight (376 of the 545 ms) disappear
     anyway, and **step 2** stops the app from calling it a second time right after login.
* Net: the theme is off the critical path in every scenario; the only visible trade is a
  one-time color flip on a brand-new browser. If that flip is unacceptable for a given tenant,
  keep the current blocking behavior for the first visit only (cache miss) and you still win on
  every later visit.

### 8.3 "Switch the icons with other ones" — don't

* Icon fonts are tree-shaken by the release build, so they are already small: MaterialIcons
  44 KB (21 KB Brotli), Font Awesome solid + regular + brands 23 KB (\~12 KB), Cupertino 3 KB,
  Syncfusion grid icons 3.6 KB. \*\*74 KB and 7 requests today, \~40 KB and 5 requests after 6b
  * 8.1.\*\*
* Switching them to SVG widgets (what `heroicons` already is) would trade those \~40 KB for an
  SVG parse per icon at build time and a code change at \~290 `FontAwesomeIcons.*` call sites
  (`fa_compat.dart`). No measurable gain on the cold load; a real risk of regressions. The
  cheap wins are the two removals in 8.1 and the compression in 6b.

### 8.4 "Compress the default login right-side image" — yes, both of them

There are two images for that panel, and the test site shows the second:

| Image | Where it comes from | Size today | Target |
| :- | :- | :- | :- |
| `assets/images/authBackground.jpg` (built-in default, shown when the company has no `auth_page_image`) | the app bundle | **1322 × 1260, 196 KB** | 1000 × 950, JPEG q75 or WebP: ≈ 60–80 KB |
| `auth_page_image` of the platform theme (`/storage/files/4_auth_bg_blue.jpg` on test) | admin upload, backend `/storage/` | **5120 × 2880, 412 KB** | 1600 × 900: ≈ 90 KB (step 7) |

Both load after the form (`CachedAuthImage` / `DecorationImage`), so they move "fully painted",
not "form painted": −0.3 s cold fast link, −0.8 s at 8 Mbit/s for the uploaded one; the default
only matters on deployments without an uploaded image. Add the resize-on-upload of step 7 so a
future upload cannot bring the 5120 px file back, and resize the three flag PNGs at the same
time (second bullet of step 7).

### 8.5 Two small extras in the same waterfall

* **Cropper.js CSS + JS from cdnjs on every page load** (13 KB, a third origin with its own
  DNS + TLS, `defer`): it is only used by the image cropper. Load it on demand from the widget
  that needs it (inject the `<script>` when the cropper opens) and drop the two `<link>` /
  `<script>` tags from `web/index.html`. ≈ 0.1 s of parallel work and one origin fewer on the
  cold load; nothing on warm loads.
* **`AssetManifest.bin.json` (22 KB) is fetched after the theme call**, i.e. on the critical
  path of the first image. Nothing to change in the app (the engine requests it on the first
  `rootBundle` use); it just disappears behind the fonts once step 8 removes the theme wait.

### 8.6 What all of this adds up to (student, cold, fast link)

| | Login form painted | Fully painted |
| :- | -: | -: |
| Today (measured) | ≈ 3.25 s | ≈ 3.7 s |
| After steps 6–8 | ≈ 2.7 s | ≈ 2.9–3.0 s |
| After steps 6–8 + §8.1 / 8.2 / 8.4 / 8.5 | **≈ 2.65 s** | **≈ 2.8 s** |
| Of which the bundle + compile (unchanged, §6) | 1.94 s | 1.94 s |

Past this point the student cold load is the Dart bundle and its compile. The next lever is the
code-splitting plan noted in §6, not fonts, icons or images.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.