diff --git a/docs/hook-event-brief.md b/docs/hook-event-brief.md
new file mode 100644
index 0000000000..8021efa61c
--- /dev/null
+++ b/docs/hook-event-brief.md
@@ -0,0 +1,149 @@
+# Hook and Event subsystem: scan and propose
+
+**Scan and propose only. Do not write implementation code. Do not open a PR.**
+
+Read `docs/refactor-facts.md` first; it outranks this file.
+
+---
+
+## Why this is different from the phase work
+
+The hook system is the plugin ABI. Every bundled plugin and every third-party
+plugin registers through `EventManager::register()` and is dispatched through
+`HookManager::processEvent()`. ADR 0013 promises plugin authors that code
+written against 1.6 keeps working for all of 1.6.
+
+If this goes out wrong, every plugin on every server breaks at once, and the
+symptom is silent: a hook that fails to register logs a line and returns
+normally. Nobody finds out until a feature quietly stops happening.
+
+So the bar here is higher than "the tests pass." The bar is: **no plugin, core
+or third-party, needs editing.** Anything that would require a plugin author to
+change a line is not a refactor, it is a breaking change, and it needs to be
+identified as one and decided on separately.
+
+---
+
+## Goals, in priority order
+
+1. **Correctness.** Several things below are wrong today, not merely ugly.
+2. **The contract stays.** `register()`, `processEvent()`, `notify()`,
+ `hasListeners()` keep working for existing callers.
+3. **Efficiency, measured not assumed.** I want this lean in memory and code,
+ but do not optimize on instinct. Measure what it costs now (how many hook
+ and event objects are constructed per request, how much of that a typical
+ request ever dispatches) and report it before proposing anything that trades
+ clarity for speed. If the current cost is already small, say so and drop
+ this goal.
+
+---
+
+## What I have observed
+
+These are observations, not a specification of the fix. Verify each one
+independently before relying on it; if any is wrong, say so.
+
+`packages/web/lib/fog/eventmanager.class.php`,
+`hookmanager.class.php`, `event.class.php`, `hook.class.php`.
+
+**1. `load()` distinguishes the two managers with two sequential `instanceof`
+checks** (`eventmanager.class.php:218-225`). `HookManager extends EventManager`,
+so a HookManager satisfies both branches. The correct file extension and
+directory are reached only because the second assignment overwrites the first.
+Reordering the two blocks changes behaviour.
+
+**2. Whether a class is active is decided by a regular expression run over the
+file's source text**, line by line (`eventmanager.class.php:248-252`), matching
+`$active\s?=\s?true;`. The class is never loaded and the property is never read
+at that point. `\s?` is zero-or-one, so spacing variations do not match, and the
+scan does not distinguish code from comments.
+
+**3. `register()` branches on `self::shortName($this)`** with a case per
+subclass and a `default` arm that throws
+(`eventmanager.class.php:71-103`). The parent enumerates its children by name.
+The throw is caught by the surrounding handler and written to the log
+(`:105-122`), so a failed registration returns normally and the caller cannot
+tell. The comment above the switch records that this shape already caused a
+total registration failure once during the namespace migration.
+
+**4. `HookManager` inherits `notify()`**, which iterates listeners as objects
+(`$element->active`, `$element->onEvent()`) while `HookManager` stores them as
+`[$object, 'method']` arrays. Determine whether anything reaches it.
+
+**5. `notify()` treats "no listeners registered" as an exception**
+(`eventmanager.class.php:175-177`) that is caught, logged, and returns false.
+`processEvent()` handles the same condition with a bare `return`
+(`hookmanager.class.php:89-91`). Same situation, two behaviours, one of which
+writes a log line on what is probably the common case.
+
+**6. `processEvent()` force-activates any listener whose file path contains the
+substring `plugins`** (`hookmanager.class.php:103-105`), regardless of its
+`active` property. Note before concluding anything: `FOGBase::fileitems()`
+builds `$regex_pgrep` from `self::$pluginsinstalled`
+(`fogbase.class.php:3648-3654`), so plugin files appear to be filtered to
+installed plugins before reaching this point. Establish whether that makes the
+force-activation redundant, or whether there is a path where it is the only
+thing making a listener run.
+
+**7. `Hook extends Event`.** Assess whether a hook is genuinely a kind of event,
+or whether the two are peers with different dispatch contracts. `processEvent()`
+merges arguments and calls a method that can mutate them through references
+embedded in the payload; `notify()` calls `onEvent()` and discards the result.
+
+---
+
+## Required before you propose anything
+
+**Inventory the contract.** Every call site of `register()`, `processEvent()`,
+`notify()` and `hasListeners()` in `fogproject` AND in `fog-plugins`. What
+shapes of listener are actually passed, what payloads are actually mutated. The
+contract is what the callers do, not what the docblocks say.
+
+**Characterize current behaviour before changing it.** Write tests that pin what
+happens today, including the parts that are wrong. Then a behaviour change shows
+up as a test that has to be edited, deliberately, rather than as a silent
+difference.
+
+**For each defect, determine whether anything currently depends on it.** This is
+the part I care most about. Two concrete examples:
+
+- If activation stops being a source-text regex and starts reading the property,
+ a hook whose property is `false` but whose file contains the literal string
+ somewhere (a comment, an example) is active today and would go inactive.
+ Search `fog-plugins` and core for any file where the regex result and the real
+ property value disagree.
+- If the path-substring force-activation is removed, any plugin hook whose
+ `$active` is not `true` becomes inactive. Search `fog-plugins` for hooks that
+ do not set it true.
+
+Report what you find. Do not assume the answer is zero.
+
+**Say what a third-party plugin cannot see.** `fog-plugins` is the plugins I
+ship. Servers run plugins I have never read. Any change whose safety argument
+depends on inspecting plugin source is weaker than one that does not, and I need
+that distinction stated explicitly for each proposal.
+
+---
+
+## What to bring back
+
+A plan in the same form as the phase plans: `VERIFIED` / `INFERRED` / `UNKNOWN`
+throughout, commit-by-commit with the tree green after every commit, a shell
+command per claim, alternatives considered and rejected, and the blast radius of
+each step for plugin authors.
+
+Plus, and separately from the plan:
+
+1. **The defect list as you found it**, including anything above that turns out
+ to be wrong or that you found and I did not.
+2. **The decisions that are mine, not yours.** Where fixing a defect would
+ change behaviour something might rely on, do not pick. Present the options
+ and what each costs. Bugs that are load-bearing for somebody are not
+ automatically bugs to fix.
+3. **The measurement**, per goal 3 above.
+4. **Whether this warrants an ADR.** It changes an ABI that ADR 0013 made
+ promises about, so my instinct is yes, but argue it.
+
+Ask me questions before planning if the code does not answer something.
+
+**End with: which claim in this plan, if false, would hurt most?**
diff --git a/docs/refactor-brief.md b/docs/refactor-brief.md
new file mode 100644
index 0000000000..b5016679be
--- /dev/null
+++ b/docs/refactor-brief.md
@@ -0,0 +1,248 @@
+# FOG working-1.6: Modernization Planning Brief
+
+You are producing **plans, not implementations**. Read this whole file before
+doing anything.
+
+There are four phases. They are ordered by dependency, not by importance.
+**Plan one phase at a time and stop for my approval at each gate.** Do not
+plan Phase 2 until I have approved Phase 1. If you find yourself planning
+across phase boundaries, stop and tell me why the boundary is wrong.
+
+---
+
+## Ground rules (apply to every phase)
+
+Before writing anything, read the actual code. Do not plan from assumption.
+Every factual claim you make about how FOG currently works must cite
+`file:line`, and you must have actually opened that file.
+
+Mark every statement as one of:
+
+- `VERIFIED` - I read this in the code, here is file:line
+- `INFERRED` - this follows from what I read but I did not confirm it
+- `UNKNOWN` - I could not determine this
+
+If the INFERRED and UNKNOWN sections are short, you have not looked hard
+enough. I would rather have twenty honest unknowns than a confident plan
+that is wrong in three places.
+
+Each phase plan must contain:
+
+1. A **commit-by-commit sequence** where the application boots and the test
+ suite passes after every single commit. No step may leave the tree broken
+ pending a later step.
+2. For each commit, a **shell command** (grep, test, curl) I can run to verify
+ it did what it claims. If a claim cannot be reduced to a command, say so
+ explicitly rather than inventing one.
+3. **Alternatives considered and rejected**, with reasons. Match the style of
+ `docs/adr/0009-plugins-become-installable-artifacts.md`.
+4. Every **irreversible step** and every **data migration** called out
+ separately, each with its rollback story.
+5. What breaks for a **third-party plugin author**, and the deprecation window.
+
+Ask me questions before planning if the codebase does not answer something.
+Do not guess and proceed.
+
+At the end of each phase plan, answer this directly: **which claim in this
+plan, if false, would hurt most?** I will verify that one by hand before
+approving.
+
+---
+
+## Why this order
+
+- Composer must exist before the auth work, because the entire argument for
+ provider-based auth is not hand-rolling JWT validation.
+- The backslash-prefix pass must happen before anything else is written, or
+ new code needs the same treatment later.
+- Site and RBAC must settle before auth, because auth maps IdP claims to
+ roles and those roles need to be stable first.
+- Namespacing goes last because it touches every file. Anything in flight
+ during it becomes a merge conflict factory. The bridge autoloader from
+ Phase 0 is what allows it to be last without blocking the rest.
+
+---
+
+## PHASE 0: Foundation
+
+Read `packages/web/commons/init.php` (the autoloader, `autoload()` around
+line 256) and `packages/web/lib/fog/fogbase.class.php` `getClass()` around
+line 531.
+
+Plan three independent changes, as separate PRs, in this order:
+
+**0.1 - Backslash-prefix every unqualified global class reference** across
+`packages/web`. `\Exception`, `\DateTime`, `\DateTimeZone`, `\PharData`,
+`\ReflectionClass`, `\PDO`, `\RecursiveIteratorIterator`, and anything else
+you find. In the global namespace this is a no-op, which is the point: it
+must be reviewable as a purely mechanical diff with zero behaviour change.
+Give me the exact count per class before proposing the change.
+
+This exists because inside a namespace, `catch (Exception $e)` silently
+becomes `catch (FOG\Exception $e)`, which fails only on the error path, only
+at runtime. Defusing it now, in a boring standalone commit, is what turns
+the scary part of Phase 3 into a non-event.
+
+**0.2 - Bridge autoloader.** Make the existing autoloader resolve a
+namespaced request by falling back to the short name and `class_alias`ing
+the result, so `FOG\User` works before any file is namespaced. Handle the
+case where the included file does not declare the expected class name.
+Note that `autoload()` builds a lowercased basename map, so a namespaced
+lookup currently misses entirely and silently.
+
+**0.3 - composer.json** with PSR-4 `FOG\` mapped to a new, empty
+`packages/web/src/`, registered alongside the existing autoloader rather
+than replacing it. `spl_autoload_register` is a chain; both can coexist.
+
+For 0.3 the real question is operational, not code. FOG installs on
+air-gapped and firewalled networks and currently vendors nothing. Lay out
+committing `vendor/` to the repo versus running `composer install` at
+install time: failure modes, repo size, update story, what happens when
+Packagist is unreachable mid-install, and how `bin/fetch-plugins.sh`
+interacts with either choice. Recommend one and argue for it.
+
+Also answer: does anything compare class names as strings, e.g.
+`get_class($x) == 'Host'` or `::class` against a literal? Those do not
+survive aliasing. Find them all and list them.
+
+**GATE. Stop here for approval.**
+
+---
+
+## PHASE 1: Site into core, alongside RBAC
+
+Read `docs/adr/0006`, `docs/adr/0009`,
+`packages/web/lib/fog/authorization.class.php`, and the `site` plugin at the
+external plugin root.
+
+Plan moving site's scope enforcement into core alongside RBAC.
+
+**Design constraint I have already decided: no enable flag.** Sites are
+unconditional. Zero sites defined means one implicit site containing
+everything, which is today's behaviour and today's out-of-box experience.
+Defining a site is what makes the boundary start to mean something. A
+boundary that exists only when a boolean is true is still a boundary that
+can be absent. Argue against this if you think I am wrong, but argue
+*before* planning, not during.
+
+Answer these from the code specifically:
+
+- `objectInScope()`, `requirePageObjectScope()` and the Mass variant
+ currently depend on the site plugin's hook listeners. What exactly happens
+ to each when no listener is registered? Quote the code path.
+- Do `registry()` or `purgePermissions($nodePrefix)` derive permission node
+ names from class names or plugin names? If yes, moving site into core
+ changes those strings, and that is a data migration that could silently
+ revoke access on live servers. **This is the thing I am most worried
+ about. Treat it as the headline question of this phase.**
+- The site plugin owns tables with real customer data. Plan the migration
+ for three cases: servers with the plugin installed, servers that never had
+ it, and a server where the migration fails halfway through.
+
+The plugin must keep existing on disk for at least one release. Plan what it
+degrades into: a shim, a no-op, or a hard error telling the admin to remove
+it.
+
+Related, and cheap: there is no `'site'` string anywhere in
+`plugin.class.php` or `pluginmanagement.page.php`, so nothing stops a future
+commit adding `fog_max` to site's manifest and silently deleting the
+security boundary via auto-deactivation. Add a CI test asserting site
+declares no `fog_max`, regardless of what else this phase does.
+
+**GATE. Stop here for approval.**
+
+---
+
+## PHASE 2: Authorization providers
+
+Read `user.class.php` `login()`, particularly the `USER_LOGGING_IN` hook
+around line 127 and the `isExternal` guard at 144-153. Read
+`authorization.class.php`. Read the `ldap` plugin's hooks.
+
+`USER_LOGGING_IN` already works for credential-presenting sources like LDAP
+and AD. It cannot express OIDC, because there is no password: the browser
+leaves for the IdP and returns with an authorization code. Plan the second
+seam.
+
+**Scope: OIDC only, not SAML.** OIDC covers Google, Microsoft Entra, Okta,
+Authentik, Keycloak, Auth0, and GitHub is close enough to share most of the
+code. SAML means a large dependency tree for a project that currently
+vendors nothing. Justify or challenge that scoping.
+
+Required:
+
+- **Split `login()`** into `authenticate()` and `establishSession(User)`.
+ Right now the only way to create a session is to know a password. Show me
+ the exact split against the real method body, including remember-me
+ cookies and `UserAuth` token handling. This refactor is worth doing on its
+ own merits regardless of SSO.
+- **A route reachable without a session** for the OIDC callback. Read the
+ router and `resolvePagePermission()` and tell me whether the
+ unregistered-page fallback is safe to rely on for an auth endpoint, or
+ whether it is a compatibility stance that will be tightened later and
+ break silently.
+- **Provisioning policy: default deny.** A successful IdP login for a user
+ with no existing FOG account fails with a clear message. JIT provisioning
+ is a later switch, not the default. Claims map to RBAC roles, never to the
+ legacy `type` field that `USER_TYPE_HOOK` rewrites.
+- **Break-glass.** Interactive SSO can never be the only way in. The FOG
+ client, storage nodes and API consumers do not do browser redirects, and
+ an IdP outage must not lock every admin out of the imaging server during
+ the same incident that made them need it. Show how `effectiveAdminExists()`
+ style reasoning applies to auth sources.
+- **Library choice** for JWT and JWKS. Hand-rolling this is how `alg: none`
+ bugs happen. Recommend a specific package, justify it on dependency
+ weight, and confirm it works under whatever vendoring decision came out of
+ Phase 0.
+
+This will be the first genuinely third-party-shaped plugin: it needs a
+route, a login page contribution, a settings page, and a session. Where does
+ADR 0009's plugin system fail to support it? That gap is more interesting to
+me than the OIDC code itself.
+
+**GATE. Stop here for approval.**
+
+---
+
+## PHASE 3: Namespacing
+
+Read `commons/init.php` `autoload()`, `fogbase.class.php` `getClass()`, and
+the class hierarchy under `packages/web/lib/fog`.
+
+Phase 0 is merged by now: globals are backslash-prefixed, the bridge
+autoloader resolves namespaced names via `class_alias`, and composer PSR-4
+maps `FOG\` to `packages/web/src/`.
+
+Plan migrating 227 classes with `class_alias` in **both directions**, so old
+and new names work simultaneously and the app works after every commit.
+
+- **Sequence: leaves first**, `FOGBase` / `FOGController` /
+ `FOGManagerController` last, since everything descends from those. Derive
+ the actual dependency graph from the code. Do not guess at what is a leaf.
+- **`getClass()` has 491 call sites and 136 distinct names.** Convert it to
+ a short-name-to-FQCN map so those call sites never need editing. This is
+ the whole reason the migration is tractable: one chokepoint controls the
+ overwhelming majority of class references.
+- While in there, remove the `global $sub` that steers `Storage` to
+ `StorageNode` or `StorageGroup`. Tell me what that breaks. It is a
+ landmine independent of namespacing.
+- **Namespace layout:** propose one and justify it. Flat `FOG\`, or
+ `FOG\Model`, `FOG\Manager`, `FOG\Page`, `FOG\Hook`? I lean toward
+ mirroring the existing directory structure but I want your argument, not
+ my assumption confirmed.
+- **Case sensitivity:** the current map lowercases everything, which hides
+ any inconsistent casing across those 491 string references. PSR-4 is
+ case-sensitive and will not hide it. Find the inconsistencies before they
+ become runtime failures.
+- **Plugins:** aliases stay through all of 1.6. Tell me what a plugin author
+ should write today, and draft the 1.7 deprecation note.
+
+Watch for permission nodes, API routes, or database columns derived from
+class names. An alias does not change what is already stored in the
+database.
+
+---
+
+## Reminder
+
+Do not plan more than one phase per response. Stop at each gate.
diff --git a/docs/route-listem-brief.md b/docs/route-listem-brief.md
new file mode 100644
index 0000000000..3cbfb303dd
--- /dev/null
+++ b/docs/route-listem-brief.md
@@ -0,0 +1,131 @@
+# `Route::listem()` and the route layer: scan and propose
+
+**Scan and propose only. Do not write implementation code. Do not open a PR.**
+
+Read `docs/refactor-facts.md` first; it outranks this file.
+
+---
+
+## What this is
+
+`packages/web/lib/router/route.class.php` is 6,470 lines, the largest file in
+the tree. `listem()` is 1,087 lines in one function. `deletemass()` is 326,
+`getter()` 309, `edit()` 259, `joining()` 168.
+
+Nobody has reviewed `listem()`, because nobody can hold 1,087 lines in their
+head at once. Whatever is wrong in there is wrong quietly and has been for
+years.
+
+---
+
+## Why this one is dangerous
+
+`listem()` is the read path for the whole API — all 55 entries in
+`Route::$validClasses` flow through it — **and** it is where object scope gets
+enforced. Everything Phase 1 built in `SiteScope` lands here.
+
+So this is not a readability exercise. A decomposition that moves a filter into
+a branch which does not always execute does not throw, does not fail a type
+check, and does not look wrong in review. It returns more rows. On a
+site-scoped server that means one customer's users seeing another site's hosts,
+and the only symptom is a list that looks fine.
+
+**Therefore, before proposing any decomposition:** map every line in `listem()`
+that participates in access control — scope filtering, permission checks, field
+redaction, anything that removes or masks a row or column. Report that map as
+its own artifact. If you cannot say with confidence which lines those are, say
+so and stop; a decomposition proposal built on an incomplete map is worse than
+no proposal.
+
+---
+
+## Hard constraints
+
+**The API response shape does not change.** Users have scripts, integrations and
+bookmarks against these routes. `Route::$validClasses` strings appear in
+customers' code. ADR 0011 governs result wrappers. Anything that alters what a
+consumer receives is a breaking change, not a refactor, and must be identified
+as one and decided separately.
+
+**`dev-branch` is out of scope.** This work is `working-1.6` only. If you find a
+defect that also exists on `dev-branch`, do not propose a port and do not shape
+the fix to make one easier. Flag it in a separate list at the end with a
+severity, and let me decide. The one exception is a security defect with a
+working exploit path, which I want told about immediately and separately from
+this plan.
+
+**No plugin should need editing.** Plugins mutate the route surface through
+`API_VALID_CLASSES` and friends (`route.class.php:451-467`). Establish what that
+hook surface actually permits and treat it as frozen.
+
+---
+
+## What I want established, in this order
+
+**1. The access-control map.** Above. This gates everything else.
+
+**2. What `listem()` actually does.** Not a summary — an enumeration of its
+responsibilities. My expectation is that it is several functions wearing a
+trenchcoat: request parsing, permission resolution, scope filtering, query
+building, joining, pagination, sorting, search, field selection, result
+shaping. Confirm or correct that, and say which responsibilities are entangled
+such that they cannot be separated without changing behaviour.
+
+**3. Whether the existing tests are a net or a comfort.**
+`route-filter-fields.test.php`, `route-result-wrappers.test.php`,
+`routed-query-string.test.php` and `openapi-route-coverage.test.php` exist. Do
+they actually pin the behaviour a refactor could break — scope filtering in
+particular — or do they cover the edges and leave the middle open? Answer with
+what they assert, not with a count. If they are not a sufficient net, then
+building one is the first commit and the decomposition is the second.
+
+**4. The bugs.** A function this size has them. Report them whether or not they
+relate to the refactor: early returns that skip later filtering, conditions that
+can never be true, `$_REQUEST` reads that bypass validation elsewhere in the
+function, silent failure paths, duplicated logic that has drifted between
+copies.
+
+**5. Efficiency, measured.** `listem()` is the hot path for every list page and
+every API read. Measure what it costs now — query count, peak memory, wall time
+on a realistic host count — before proposing anything that trades clarity for
+speed. If the current cost is fine, say so and drop this. I would rather have
+correct and readable than fast, but I want to know the number before choosing.
+
+**6. `openapi.class.php` coupling.** It is 1,753 lines and derives the published
+spec from this layer. Establish what in the route surface it reads, so a change
+here cannot silently desynchronise the documented API from the real one.
+
+---
+
+## What to bring back
+
+A plan in the established form: `VERIFIED` / `INFERRED` / `UNKNOWN` throughout,
+commit-by-commit with the tree green after every commit, a shell command per
+claim, alternatives considered and rejected, blast radius per step.
+
+Separately from the plan:
+
+1. **The access-control map**, as its own document. This outlives the refactor.
+2. **The defect list**, with anything that would need a `dev-branch` decision
+ split into its own section, severity-rated, no port proposed.
+3. **The decisions that are mine.** Where a fix changes observable behaviour,
+ present the options and their costs rather than picking. Some of what looks
+ wrong in a nine-year-old API is load-bearing for somebody.
+4. **The measurement.**
+5. **Whether this warrants an ADR.** My instinct is that a decomposition alone
+ does not, but any change to what the route layer guarantees about scope does.
+ Argue it.
+
+Ask me questions before planning if the code does not answer something.
+
+**End with: which claim in this plan, if false, would hurt most?**
+
+---
+
+## One thing I am not asking for
+
+Do not propose splitting `route.class.php` into multiple files as a first step.
+Moving 6,470 lines between files produces an enormous diff that hides behaviour
+changes inside apparent relocations, and it is exactly the shape of change I
+cannot review. If the end state wants several files, that is the last commit in
+the sequence, not the first.
diff --git a/packages/web/lib/fog/fogpage.class.php b/packages/web/lib/fog/fogpage.class.php
index d4f554a7dc..9f219f73e9 100644
--- a/packages/web/lib/fog/fogpage.class.php
+++ b/packages/web/lib/fog/fogpage.class.php
@@ -304,19 +304,19 @@ public function __construct($name = '')
parent::__construct();
self::$FOGCollapseBox = self::makeButton(
'',
- '',
+ '',
'btn btn-tool',
'data-lte-toggle="card-collapse"'
);
self::$FOGExpandBox = self::makeButton(
'',
- '',
+ '',
'btn btn-tool',
'data-lte-toggle="card-maximize"'
);
self::$FOGCloseBox = self::makeButton(
'',
- '',
+ '',
'btn btn-tool',
'data-lte-toggle="card-remove"'
);
@@ -440,78 +440,78 @@ public static function buildMainMenuItems(&$main = '', &$hookMain = '')
$menu = [
'home' => [
self::$foglang['Dashboard'],
- 'fa fa-dashboard'
+ 'fas fa-gauge-high'
],
'host' => [
self::$foglang['Hosts'],
- 'fa fa-desktop'
+ 'fas fa-desktop'
],
'group' => [
self::$foglang['Groups'],
- 'fa fa-sitemap'
+ 'fas fa-sitemap'
],
'image' => [
self::$foglang['Images'],
- 'fa fa-hdd-o'
+ 'far fa-hard-drive'
],
'snapin' => [
self::$foglang['Snapins'],
- 'fa fa-cube'
+ 'fas fa-cube'
],
'storagegroup' => [
self::$foglang['Storagegroups'],
- 'fa fa-object-group'
+ 'far fa-object-group'
],
'storagenode' => [
self::$foglang['Storagenodes'],
- 'fa fa-archive'
+ 'fas fa-server'
],
'printer' => [
self::$foglang['Printers'],
- 'fa fa-print'
+ 'fas fa-print'
],
'module' => [
_('Modules'),
- 'fa fa-cogs'
+ 'fas fa-sliders'
],
'task' => [
self::$foglang['Tasks'],
- 'fa fa-tasks'
+ 'fas fa-bars-progress'
],
'user' => [
self::$foglang['Users'],
- 'fa fa-users'
+ 'fas fa-users'
],
'usergroup' => [
_('User Groups'),
- 'fa fa-address-book'
+ 'fas fa-people-group'
],
'role' => [
_('Roles'),
- 'fa fa-key'
+ 'fas fa-user-shield'
],
// Beside roles rather than beside hosts: a site says which
// objects a user may reach, which is the same kind of answer a
// role gives about which actions.
'site' => [
_('Sites'),
- 'fa fa-map-marker'
+ 'fas fa-building'
],
'ipxe' => [
_('iPXE Menu'),
- 'fa fa-bars'
+ 'fas fa-list-ol'
],
'about' => [
self::$foglang['FOG Configuration'],
- 'fa fa-wrench'
+ 'fas fa-wrench'
],
'apidocs' => [
_('API Documentation'),
- 'fa fa-code'
+ 'fas fa-code'
],
'report' => [
self::$foglang['Reports'],
- 'fa fa-file-text'
+ 'fas fa-file-lines'
],
// Beside Reports, because that is what people will look for it
// under -- but a node of its own, not a report. The `report`
@@ -521,7 +521,7 @@ public static function buildMainMenuItems(&$main = '', &$hookMain = '')
// See docs/adr/0023.
'activity' => [
_('Activity'),
- 'fa fa-history'
+ 'fas fa-clock-rotate-left'
],
// Next to Activity, and a different page on purpose. Activity is
// the operational narrative; this is the record of who was
@@ -530,15 +530,15 @@ public static function buildMainMenuItems(&$main = '', &$hookMain = '')
// from anyone not granted it. See docs/adr/0021.
'audit' => [
_('Audit Log'),
- 'fa fa-shield'
+ 'fas fa-clipboard-list'
],
'service' => [
self::$foglang['ClientSettings'],
- 'fa fa-cogs'
+ 'fas fa-gears'
],
'client' => [
_('FOG Client'),
- 'fa fa-cloud-download'
+ 'fas fa-cloud-arrow-down'
]
];
$pluginSysOn = (bool)self::getSetting('FOG_PLUGINSYS_ENABLED');
@@ -549,7 +549,7 @@ public static function buildMainMenuItems(&$main = '', &$hookMain = '')
'plugin',
[
self::$foglang['Plugins'],
- 'fa fa-puzzle-piece'
+ 'fas fa-puzzle-piece'
]
);
}
@@ -711,7 +711,7 @@ private static function _menuGroups()
return [
'useradmin' => [
'title' => _('User Administration'),
- 'icon' => 'fa fa-shield',
+ 'icon' => 'fas fa-shield',
'children' => ['user', 'usergroup', 'role'],
],
];
@@ -745,7 +745,7 @@ function ($child) use ($menu) {
echo '';
echo '';
echo '
';
echo '';
echo '';
diff --git a/packages/web/lib/fog/plugin.class.php b/packages/web/lib/fog/plugin.class.php
index 919a8e9652..57d293b85a 100644
--- a/packages/web/lib/fog/plugin.class.php
+++ b/packages/web/lib/fog/plugin.class.php
@@ -353,7 +353,7 @@ public static function readManifest($dir)
return [
'name' => strtolower($string('name', basename(rtrim($dir, DS)))),
'description' => $string('description'),
- 'menuicon' => $string('menuicon', 'fa fa-plug fa-fw'),
+ 'menuicon' => $string('menuicon', 'fas fa-plug fa-fw'),
'version' => $string('version'),
'fog_min' => $string('fog_min'),
'fog_max' => $string('fog_max'),
diff --git a/packages/web/lib/fog/system.class.php b/packages/web/lib/fog/system.class.php
index c0263ef5b5..4c8b8bc5c5 100644
--- a/packages/web/lib/fog/system.class.php
+++ b/packages/web/lib/fog/system.class.php
@@ -95,7 +95,7 @@ public function __construct()
// permanently "up to date" from the updater's point of view and will
// never run another indexed step, whatever this constant says.
define('FOG_SCHEMA', 360);
- define('FOG_BCACHE_VER', 311);
+ define('FOG_BCACHE_VER', 313);
define('FOG_CLIENT_VERSION', '0.13.0');
// GH-959: iPXE lives in FOGProject/fog-ipxe and its binaries arrive as
// a release asset. Pinned here rather than tracked as "latest" so a
@@ -108,7 +108,7 @@ public function __construct()
// installer reads this to pick which release to download, so a given
// FOG release ships a known set of plugins rather than whatever the
// default branch held on the day someone installed.
- define('FOG_PLUGINS_VERSION', 'v1.6.14');
+ define('FOG_PLUGINS_VERSION', 'v1.6.15');
// GH-850: FOG_BASE_DIR is now installer-driven. Initiator loads
// commons/fogpaths.php (written from the installer's $fogprogramdir)
// before the autoloader runs, so in a normal boot these are already
diff --git a/packages/web/lib/pages/fogconfigurationpage.page.php b/packages/web/lib/pages/fogconfigurationpage.page.php
index a664242695..88328f74af 100644
--- a/packages/web/lib/pages/fogconfigurationpage.page.php
+++ b/packages/web/lib/pages/fogconfigurationpage.page.php
@@ -3075,7 +3075,7 @@ public function settings()
. 'placeholder="' . _('Search settings') . '" autocomplete="off">';
echo '';
+ . '';
echo '';
echo '';
echo '';
@@ -3227,7 +3227,7 @@ private function _renderSettings()
echo '
';
foreach ($catRows as $row) {
@@ -3267,7 +3267,7 @@ private function _renderSettings()
if ($wantsrefresh) {
// Visual marker only (no own tooltip); the label tooltip
// above already carries the reload note.
- echo ' ';
}
echo '';
diff --git a/packages/web/lib/pages/imagemanagement.page.php b/packages/web/lib/pages/imagemanagement.page.php
index 8b3efa5379..e9fdc5b33b 100644
--- a/packages/web/lib/pages/imagemanagement.page.php
+++ b/packages/web/lib/pages/imagemanagement.page.php
@@ -299,7 +299,7 @@ protected function _addFields()
_('Image Type')
. ' '
. self::makeInfoTooltip(
- 'icon fa fa-info-circle fa-lg hand',
+ 'icon fas fa-circle-info fa-lg hand',
'image-type-info',
sprintf(
_(
@@ -678,7 +678,7 @@ public function imageGeneral()
_('Image Type')
. ' '
. self::makeInfoTooltip(
- 'icon fa fa-info-circle fa-lg hand',
+ 'icon fas fa-circle-info fa-lg hand',
'image-type-info',
sprintf(
_(
diff --git a/packages/web/lib/pages/processlogin.page.php b/packages/web/lib/pages/processlogin.page.php
index 1c43dae0b9..20c223c347 100644
--- a/packages/web/lib/pages/processlogin.page.php
+++ b/packages/web/lib/pages/processlogin.page.php
@@ -214,7 +214,7 @@ public static function mainLoginForm()
'',
true
);
- echo '
';
+ echo '
';
echo '
';
// Password -- the trailing icon is a button that shows/hides the value
// so the user can confirm what they typed (see fog.common.js).
@@ -231,7 +231,7 @@ public static function mainLoginForm()
echo '';
+ . '';
echo '';
echo '
'
);
} else {
$(this).children('#'+loadingId).remove();;
@@ -3057,7 +3057,27 @@ function setupIntegrations() {
},
restartOnRequestAfter: false
};
- PNotify.prototype.options.styling = "bootstrap3";
+ // Toasts had no icon at all. PNotify's "bootstrap3" styling emits glyphicon
+ // classes, and glyphicons were dropped by Bootstrap 4 -- so since the BS3->BS5
+ // move every notice rendered its icon slot as an empty box: `content: none`,
+ // zero width, falling back to the body font. Silent, like every icon failure.
+ //
+ // PNotify ships a "fontawesome" styling, but its class names are FA4
+ // (fa-warning, fa-times, fa-exclamation-circle) and we ship no v4 shims, so
+ // switching to it alone would swap one set of blank boxes for another. The
+ // names are overridden here, at our call site, rather than in the vendored
+ // pnotify.min.js -- editing that file means the next upgrade of it silently
+ // reverts the fix, and it is not ours to change.
+ PNotify.prototype.options.styling = "fontawesome";
+ $.extend(PNotify.styling.fontawesome, {
+ notice_icon: 'fas fa-circle-exclamation',
+ info_icon: 'fas fa-circle-info',
+ success_icon: 'fas fa-circle-check',
+ error_icon: 'fas fa-triangle-exclamation',
+ closer: 'fas fa-xmark',
+ pin_up: 'fas fa-pause',
+ pin_down: 'fas fa-play'
+ });
// Extending input mask to add our types (absent on the slim asset set)
if ($.inputmask) {
@@ -3156,7 +3176,7 @@ function setupUniversalSearch() {
function setupPasswordReveal() {
$(':password')
.not('.fakes, [name="upass"]')
- .before('');
+ .before('');
// These are delegated on `document`, which survives AJAX page swaps, while
// reinitialize() (and thus this function) runs again on every AJAX page
// load. Namespace and remove them first so they don't accumulate -- two
diff --git a/packages/web/management/js/fog/group/fog.group.edit.js b/packages/web/management/js/fog/group/fog.group.edit.js
index ca4edaa2c4..f7e58e7f71 100644
--- a/packages/web/management/js/fog/group/fog.group.edit.js
+++ b/packages/web/management/js/fog/group/fog.group.edit.js
@@ -474,13 +474,13 @@
'type': 'button',
'class': 'btn btn-sm btn-secondary snapin-order-up',
'title': 'Move up'
- }).append($('', {'class': 'fa fa-arrow-up'})),
+ }).append($('', {'class': 'fas fa-arrow-up'})),
' ',
$('
diff --git a/packages/web/management/webfonts/LICENSE-fontawesome.txt b/packages/web/management/webfonts/LICENSE-fontawesome.txt
new file mode 100644
index 0000000000..69403b25cd
--- /dev/null
+++ b/packages/web/management/webfonts/LICENSE-fontawesome.txt
@@ -0,0 +1,165 @@
+Fonticons, Inc. (https://fontawesome.com)
+
+--------------------------------------------------------------------------------
+
+Font Awesome Free License
+
+Font Awesome Free is free, open source, and GPL friendly. You can use it for
+commercial projects, open source projects, or really almost whatever you want.
+Full Font Awesome Free license: https://fontawesome.com/license/free.
+
+--------------------------------------------------------------------------------
+
+# Icons: CC BY 4.0 License (https://creativecommons.org/licenses/by/4.0/)
+
+The Font Awesome Free download is licensed under a Creative Commons
+Attribution 4.0 International License and applies to all icons packaged
+as SVG and JS file types.
+
+--------------------------------------------------------------------------------
+
+# Fonts: SIL OFL 1.1 License
+
+In the Font Awesome Free download, the SIL OFL license applies to all icons
+packaged as web and desktop font files.
+
+Copyright (c) 2026 Fonticons, Inc. (https://fontawesome.com)
+with Reserved Font Name: "Font Awesome".
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+SIL OPEN FONT LICENSE
+Version 1.1 - 26 February 2007
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting — in part or in whole — any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
+
+--------------------------------------------------------------------------------
+
+# Code: MIT License (https://opensource.org/licenses/MIT)
+
+In the Font Awesome Free download, the MIT license applies to all non-font and
+non-icon files.
+
+Copyright 2026 Fonticons, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in the
+Software without restriction, including without limitation the rights to use, copy,
+modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
+and to permit persons to whom the Software is furnished to do so, subject to the
+following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+--------------------------------------------------------------------------------
+
+# Attribution
+
+Attribution is required by MIT, SIL OFL, and CC BY licenses. Downloaded Font
+Awesome Free files already contain embedded comments with sufficient
+attribution, so you shouldn't need to do anything additional when using these
+files normally.
+
+We've kept attribution comments terse, so we ask that you do not actively work
+to remove them from files, especially code. They're a great way for folks to
+learn about Font Awesome.
+
+--------------------------------------------------------------------------------
+
+# Brand Icons
+
+All brand icons are trademarks of their respective owners. The use of these
+trademarks does not indicate endorsement of the trademark holder by Font
+Awesome, nor vice versa. **Please do not use brand logos for any purpose except
+to represent the company, product, or service to which they refer.**
diff --git a/packages/web/management/webfonts/fa-brands-400.woff2 b/packages/web/management/webfonts/fa-brands-400.woff2
new file mode 100644
index 0000000000..e5b3ce0812
Binary files /dev/null and b/packages/web/management/webfonts/fa-brands-400.woff2 differ
diff --git a/packages/web/management/webfonts/fa-regular-400.woff2 b/packages/web/management/webfonts/fa-regular-400.woff2
new file mode 100644
index 0000000000..d470bd1758
Binary files /dev/null and b/packages/web/management/webfonts/fa-regular-400.woff2 differ
diff --git a/packages/web/management/webfonts/fa-solid-900.woff2 b/packages/web/management/webfonts/fa-solid-900.woff2
new file mode 100644
index 0000000000..3ffbb19eb1
Binary files /dev/null and b/packages/web/management/webfonts/fa-solid-900.woff2 differ
diff --git a/packages/web/management/webfonts/fa-v4compatibility.woff2 b/packages/web/management/webfonts/fa-v4compatibility.woff2
new file mode 100644
index 0000000000..971a2b3273
Binary files /dev/null and b/packages/web/management/webfonts/fa-v4compatibility.woff2 differ
diff --git a/tests/fontawesome7-icon-names.test.php b/tests/fontawesome7-icon-names.test.php
new file mode 100644
index 0000000000..ea5a15eb66
--- /dev/null
+++ b/tests/fontawesome7-icon-names.test.php
@@ -0,0 +1,231 @@
+check('the Font Awesome stylesheet is vendored', is_file($css));
+
+$cssSrc = is_file($css) ? file_get_contents($css) : '';
+$t->check(
+ 'it is Font Awesome 7',
+ (bool)preg_match('/Font Awesome Free 7\./', $cssSrc)
+);
+// Shims are the thing whose absence every other check here depends on.
+$t->check(
+ 'no v4 shim stylesheet is vendored',
+ [] === glob($web . '/management/css/*v4-shims*')
+);
+$t->check(
+ 'and nothing loads one',
+ false === strpos(
+ file_get_contents($web . '/lib/fog/page.class.php'),
+ 'v4-shims'
+ )
+);
+// The FA4 webfont set is gone; its replacements are present.
+$t->check(
+ 'the FA4 webfonts are no longer shipped',
+ [] === glob($web . '/management/fonts/fontawesome-webfont.*')
+ && !is_file($web . '/management/fonts/FontAwesome.otf')
+);
+foreach (['fa-solid-900', 'fa-regular-400', 'fa-brands-400'] as $font) {
+ $t->check(
+ sprintf('webfont %s.woff2 is shipped', $font),
+ is_file($web . '/management/webfonts/' . $font . '.woff2')
+ );
+}
+// Every font the stylesheet asks for must exist, or those glyphs are blank.
+preg_match_all('#url\(\.\./webfonts/([a-z0-9.-]+)\)#i', $cssSrc, $m);
+$missing = [];
+foreach (array_unique($m[1]) as $file) {
+ if (!is_file($web . '/management/webfonts/' . $file)) {
+ $missing[] = $file;
+ }
+}
+$t->check(
+ sprintf(
+ 'every webfont the stylesheet references exists (%d referenced)%s',
+ count(array_unique($m[1])),
+ [] === $missing ? '' : ' -- MISSING: ' . implode(', ', $missing)
+ ),
+ [] === $missing
+);
+
+// ---------------------------------------------------------------------------
+// 2. The call sites.
+//
+// lib/plugins is excluded deliberately: it is gitignored staging for the
+// separate fog-plugins repo, which carries its own copy of this check. Core
+// cannot fix a name that lives there, and failing on it would make this file
+// unfixable from inside this repo.
+// ---------------------------------------------------------------------------
+$files = [];
+$it = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($web, \FilesystemIterator::SKIP_DOTS)
+);
+foreach ($it as $f) {
+ $path = $f->getPathname();
+ if (false !== strpos($path, '/vendor/')
+ || false !== strpos($path, '/lib/plugins/')
+ ) {
+ continue;
+ }
+ // Vendored bundles carry their own icon names and we do not get to rename
+ // them -- pnotify.min.js names FA4 icons in its styling presets. Ours live
+ // in management/js/fog/; everything directly under management/js/ is a
+ // third-party bundle. Where a vendored preset would render blank, FOG
+ // overrides it at its own call site (see the PNotify styling block in
+ // fog.common.js, pinned below) rather than editing the bundle, because
+ // editing it means the next upgrade silently reverts the fix.
+ if (preg_match('#/management/js/[^/]+\.js$#', $path)) {
+ continue;
+ }
+ if (preg_match('/\.(php|js|css|scss)$/', $path)) {
+ $files[] = $path;
+ }
+}
+$t->check('source files were found to scan', count($files) > 100);
+
+$bare = [];
+$used = [];
+foreach ($files as $path) {
+ $src = file_get_contents($path);
+ if (preg_match_all('/\bfa fa-([a-z0-9-]+)/', $src, $mm)) {
+ foreach ($mm[1] as $name) {
+ $bare[$name][] = str_replace($root . '/', '', $path);
+ }
+ }
+ if (preg_match_all('/\bfa[bsrl] fa-([a-z0-9-]+)/', $src, $mm)) {
+ foreach ($mm[1] as $name) {
+ $used[$name] = true;
+ }
+ }
+}
+$t->check(
+ sprintf(
+ 'no call site uses the bare "fa fa-*" form%s',
+ [] === $bare ? '' : ' -- FOUND: ' . implode(', ', array_keys($bare))
+ ),
+ [] === $bare
+);
+
+// ---------------------------------------------------------------------------
+// 3. Every emitted name resolves in the shipped stylesheet.
+//
+// This is the check that catches a hand-written icon nobody ran the migration
+// over, and a pro-only name -- which is absent from the free build and renders
+// exactly as blank as an FA4 one.
+// ---------------------------------------------------------------------------
+$modifiers = [
+ 'fw', 'lg', 'sm', 'xs', 'spin', 'pulse', 'border', 'li', 'inverse',
+ 'stack', 'stack-1x', 'stack-2x', 'rotate-90', 'rotate-180', 'rotate-270',
+ 'flip-horizontal', 'flip-vertical', 'flip-both', 'beat', 'fade', 'bounce',
+ 'shake', 'spin-pulse', 'spin-reverse', 'pull-left', 'pull-right',
+ '2x', '3x', '4x', '5x', '6x', '7x', '8x', '9x', '10x', 'solid', 'regular',
+ 'brands', 'classic', 'sharp'
+];
+$unresolved = [];
+foreach (array_keys($used) as $name) {
+ if (in_array($name, $modifiers, true)) {
+ continue;
+ }
+ // FA emits one rule per icon; an alias gets its own selector too, so a
+ // name that resolves at all appears here.
+ if (!preg_match('/\.fa-' . preg_quote($name, '/') . '[,:{ ]/', $cssSrc)) {
+ $unresolved[] = $name;
+ }
+}
+$t->check(
+ sprintf(
+ 'every emitted icon name resolves in the shipped CSS (%d distinct)%s',
+ count($used),
+ [] === $unresolved ? '' : ' -- UNRESOLVED: ' . implode(', ', $unresolved)
+ ),
+ [] === $unresolved
+);
+
+// ---------------------------------------------------------------------------
+// 4. Vendored presets that would otherwise render blank.
+//
+// PNotify's own styling presets are FA4. FOG used its "bootstrap3" preset,
+// which emits glyphicon classes -- dropped by Bootstrap 4 -- so from the BS3
+// to BS5 move until this migration every toast rendered its icon slot as an
+// empty box. Swapping to PNotify's "fontawesome" preset alone would have
+// traded glyphicons for FA4 names, which are equally blank here.
+// ---------------------------------------------------------------------------
+$common = file_get_contents($web . '/management/js/fog/fog.common.js');
+$t->check(
+ 'PNotify is not left on the glyphicon-emitting bootstrap3 preset',
+ false === strpos($common, 'options.styling = "bootstrap3"')
+);
+$t->check(
+ 'and its FA4 preset names are overridden with FA7 ones',
+ (bool)preg_match(
+ '/PNotify\.styling\.fontawesome,\s*\{/',
+ $common
+ )
+ && (bool)preg_match("/error_icon: 'fas fa-triangle-exclamation'/", $common)
+ && (bool)preg_match("/closer: 'fas fa-xmark'/", $common)
+);
+
+$t->finish();