harden: restore gate baseline, split dev/prod PHP profile, atomic module runtime build

Phase A — Gate Reliability:
- Fix typography token violations in app-breadcrumb.css
- Switch QG-006 from composer cs:check to direct php-cs-fixer
- Align all docs/skills with new gate command

Phase B — Production Profile:
- Multi-stage Dockerfile: dev (xdebug) / prod (no xdebug)
- Compose files target explicit build stages

Phase C — Module Runtime Hardening:
- Atomic staging+swap build in ModuleRuntimePageBuilder
- SHA-256 fingerprint from manifest content + page entries
- 11 new regression tests for build safety and fingerprint drift

Task: STARTERKIT-HARDENING-ONPREM-001

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 20:13:16 +01:00
parent 1c5648c727
commit c34e62d729
12 changed files with 475 additions and 75 deletions

View File

@@ -36,7 +36,7 @@
"id": "QG-006",
"type": "fast",
"name": "PHP Style Check",
"command": "docker compose exec php composer cs:check"
"command": "docker compose exec php vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --dry-run --diff --verbose"
},
{
"id": "QG-007",

View File

@@ -17,7 +17,7 @@ Coding-Style pruefbar, reproduzierbar und regressionsarm machen.
## Verbindliche Regeln
1. MUST `composer cs:check` fuer nicht-mutierende Pruefung nutzen.
1. MUST `vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --dry-run --diff --verbose` fuer nicht-mutierende Pruefung nutzen (Convenience-Alias: `composer cs:check`).
2. MUST `composer cs:fix` nur bewusst und mit Diff-Kontrolle ausfuehren.
3. MUST Style-Check vor Merge gruen halten (lokal und optional CI).
4. MUST nach groesseren Fixes mindestens `phpunit` und `phpstan` laufen lassen.
@@ -25,7 +25,7 @@ Coding-Style pruefbar, reproduzierbar und regressionsarm machen.
## Workflow
1. Baseline erfassen: `composer cs:check`.
1. Baseline erfassen: `vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --dry-run --diff --verbose`.
2. Falls noetig fixen: `composer cs:fix`.
3. Diff fokussiert pruefen (Imports, Braces, Nebenwirkungen).
4. Qualitaetsgates laufen lassen.

View File

@@ -7,7 +7,7 @@ CI soll nur pruefen, nicht formatieren.
## Minimaler Check
```bash
composer cs:check
vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --dry-run --diff --verbose
```
## Erwartung

View File

@@ -3,13 +3,13 @@
## Commands
```bash
composer cs:check
vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --dry-run --diff --verbose
composer cs:fix
```
## Praktikabler Ablauf
1. Erst `cs:check` laufen lassen.
1. Erst Style-Check laufen lassen (`vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --dry-run --diff --verbose`).
2. Danach `cs:fix`.
3. Nur relevante Diffs uebernehmen, grosse Misch-Diffs vermeiden.
4. Anschliessend:

View File

@@ -209,7 +209,7 @@ These rules are enforced by guards GR-SEC-001 through GR-SEC-009 in `.agents/che
| QG-001 | PHPUnit | `vendor/bin/phpunit` |
| QG-002 | PHPStan level 5 | `vendor/bin/phpstan analyse -c phpstan.neon` |
| QG-003 | Architecture Contract | `vendor/bin/phpunit tests/Architecture/CoreStarterkitContractTest.php` |
| QG-006 | PHP Style | `composer cs:check` |
| QG-006 | PHP Style | `vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --dry-run --diff --verbose` |
Additional gates (fast/periodic): QG-004 structural checks, QG-005 JS smoke, QG-007 composer-unused, QG-008 docs links, QG-009 skills sync. See gate catalog for details.

View File

@@ -220,7 +220,7 @@ docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress
CI-Check (ohne Dateien zu aendern):
```bash
docker compose exec php composer cs:check
docker compose exec php vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --dry-run --diff --verbose
```
Lokal automatisch formatieren:

View File

@@ -16,6 +16,7 @@ services:
build:
context: .
dockerfile: docker/php/Dockerfile
target: prod
env_file:
- .env
environment:
@@ -23,7 +24,6 @@ services:
APP_DEBUG: "0"
volumes:
- ./:/var/www
- ./docker/php/php.prod.ini:/usr/local/etc/php/conf.d/zzz-minty-dev.ini:ro
depends_on:
- db
- memcached
@@ -33,6 +33,7 @@ services:
build:
context: .
dockerfile: docker/php/Dockerfile
target: prod
env_file:
- .env
environment:
@@ -40,7 +41,6 @@ services:
APP_DEBUG: "0"
volumes:
- ./:/var/www
- ./docker/php/php.prod.ini:/usr/local/etc/php/conf.d/zzz-minty-dev.ini:ro
depends_on:
- db
- memcached

View File

@@ -13,6 +13,7 @@ services:
build:
context: .
dockerfile: docker/php/Dockerfile
target: dev
env_file:
- .env
volumes:
@@ -25,6 +26,7 @@ services:
build:
context: .
dockerfile: docker/php/Dockerfile
target: dev
env_file:
- .env
environment:

View File

@@ -1,4 +1,4 @@
FROM php:8.5-fpm
FROM php:8.5-fpm AS base
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
@@ -11,14 +11,27 @@ RUN apt-get update \
libjpeg-dev \
libpng-dev \
libwebp-dev \
&& pecl install memcached xdebug \
&& docker-php-ext-enable memcached xdebug \
&& pecl install memcached \
&& docker-php-ext-enable memcached \
&& docker-php-ext-configure gd --with-jpeg --with-webp \
&& docker-php-ext-install pdo_mysql mysqli gd zip \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www
# ── Development profile (default) ────────────────────────────────────
FROM base AS dev
RUN pecl install xdebug \
&& docker-php-ext-enable xdebug
COPY docker/php/php.ini /usr/local/etc/php/conf.d/zzz-minty-dev.ini
COPY docker/php/xdebug.ini /usr/local/etc/php/conf.d/zzz-xdebug.ini
WORKDIR /var/www
# ── Production profile ───────────────────────────────────────────────
# No xdebug — smaller image, no debug overhead or attack surface.
FROM base AS prod
COPY docker/php/php.prod.ini /usr/local/etc/php/conf.d/zzz-minty-prod.ini

View File

@@ -9,6 +9,10 @@ use RuntimeException;
/**
* Builds a runtime PageRoot by symlinking core and enabled module pages.
*
* Build uses atomic staging+swap: output is constructed in a temporary staging
* directory alongside the live target and only swapped into place after
* successful validation. Partial failures cannot leave inconsistent live state.
*/
final class ModuleRuntimePageBuilder
{
@@ -28,65 +32,21 @@ final class ModuleRuntimePageBuilder
return ['core_entries' => 0, 'module_entries' => 0];
}
if (is_link($runtimeDir)) {
unlink($runtimeDir);
}
if (!is_dir($runtimeDir) && !mkdir($runtimeDir, 0775, true) && !is_dir($runtimeDir)) {
throw new RuntimeException('Cannot create runtime pages directory: ' . $runtimeDir);
// ── Staging: build in a sibling directory, swap on success ────
$stagingDir = dirname($runtimeDir) . '/.pages-staging-' . getmypid();
$this->ensureCleanDirectory($stagingDir);
try {
$result = $this->populateDirectory($stagingDir, $corePages, $modules);
} catch (\Throwable $e) {
$this->removeDirectory($stagingDir);
throw $e;
}
$this->removeDirectoryContents($runtimeDir);
// ── Atomic swap ──────────────────────────────────────────────
$this->swapIntoLive($stagingDir, $runtimeDir);
$coreEntries = scandir($corePages);
if ($coreEntries === false) {
throw new RuntimeException('Cannot scan core pages directory.');
}
$coreCount = 0;
foreach ($coreEntries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$source = $corePages . '/' . $entry;
$target = $runtimeDir . '/' . $entry;
if (!@symlink($source, $target)) {
throw new RuntimeException("Failed to create symlink: {$target}{$source}");
}
$coreCount++;
}
$modulePageCount = 0;
foreach ($modules as $manifest) {
$modulePagesDir = $manifest->basePath . '/pages';
if (!is_dir($modulePagesDir)) {
continue;
}
$entries = scandir($modulePagesDir);
if ($entries === false) {
continue;
}
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$source = $modulePagesDir . '/' . $entry;
$target = $runtimeDir . '/' . $entry;
if (file_exists($target) || is_link($target)) {
throw new RuntimeException(
"Page path collision: '{$entry}' from module '{$manifest->id}' conflicts with existing page."
);
}
if (!@symlink($source, $target)) {
throw new RuntimeException("Failed to create symlink: {$target}{$source}");
}
$modulePageCount++;
}
}
return [
'core_entries' => $coreCount,
'module_entries' => $modulePageCount,
];
return $result;
}
public function pointRuntimeToCore(string $runtimeDir, string $corePages): void
@@ -108,14 +68,36 @@ final class ModuleRuntimePageBuilder
/**
* Compute the expected fingerprint for a set of modules.
*
* The digest includes module IDs, each manifest file content hash,
* and top-level page-entry signatures so that manifest-only or
* page-structure changes are detected even when the module ID list
* is unchanged.
*
* @param array<string, ModuleManifest> $modules
*/
public static function expectedFingerprint(array $modules): string
{
$ids = array_map(static fn (ModuleManifest $m): string => $m->id, $modules);
sort($ids);
$parts = [];
return implode(',', $ids);
$manifests = $modules;
uasort($manifests, static fn (ModuleManifest $a, ModuleManifest $b): int => $a->id <=> $b->id);
foreach ($manifests as $manifest) {
$manifestFile = $manifest->basePath . '/module.php';
$manifestHash = is_file($manifestFile)
? md5_file($manifestFile)
: 'missing';
$pageEntries = self::topLevelPageEntries($manifest->basePath . '/pages');
$parts[] = $manifest->id . ':' . $manifestHash . ':' . implode(',', $pageEntries);
}
if ($parts === []) {
return '';
}
return hash('sha256', implode('|', $parts));
}
/**
@@ -141,6 +123,153 @@ final class ModuleRuntimePageBuilder
file_put_contents($file, self::expectedFingerprint($modules));
}
// ── Internal helpers ────────────────────────────────────────────
/**
* Populate a directory with core + module page symlinks.
*
* @param array<string, ModuleManifest> $modules
* @return array{core_entries: int, module_entries: int}
*/
private function populateDirectory(string $targetDir, string $corePages, array $modules): array
{
$coreEntries = scandir($corePages);
if ($coreEntries === false) {
throw new RuntimeException('Cannot scan core pages directory.');
}
$coreCount = 0;
foreach ($coreEntries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$source = $corePages . '/' . $entry;
$target = $targetDir . '/' . $entry;
if (!@symlink($source, $target)) {
throw new RuntimeException("Failed to create symlink: {$target}{$source}");
}
$coreCount++;
}
$modulePageCount = 0;
foreach ($modules as $manifest) {
$modulePagesDir = $manifest->basePath . '/pages';
if (!is_dir($modulePagesDir)) {
continue;
}
$entries = scandir($modulePagesDir);
if ($entries === false) {
continue;
}
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$source = $modulePagesDir . '/' . $entry;
$target = $targetDir . '/' . $entry;
if (file_exists($target) || is_link($target)) {
throw new RuntimeException(
"Page path collision: '{$entry}' from module '{$manifest->id}' conflicts with existing page."
);
}
if (!@symlink($source, $target)) {
throw new RuntimeException("Failed to create symlink: {$target}{$source}");
}
$modulePageCount++;
}
}
return [
'core_entries' => $coreCount,
'module_entries' => $modulePageCount,
];
}
/**
* Swap staging directory into the live runtime path.
*
* Uses rename() for an atomic filesystem operation (same filesystem).
*/
private function swapIntoLive(string $stagingDir, string $runtimeDir): void
{
// Remove existing live target
$oldDir = null;
if (is_link($runtimeDir)) {
unlink($runtimeDir);
} elseif (is_dir($runtimeDir)) {
// Move old live dir aside, then remove after swap
$oldDir = $runtimeDir . '.old-' . getmypid();
if (!@rename($runtimeDir, $oldDir)) {
$this->removeDirectoryContents($runtimeDir);
rmdir($runtimeDir);
$oldDir = null;
}
} else {
$oldDir = null;
}
// Atomic rename of staging → live
if (!@rename($stagingDir, $runtimeDir)) {
throw new RuntimeException(
"Failed to swap staging directory into live runtime path: {$stagingDir}{$runtimeDir}"
);
}
// Clean up old directory if we moved it aside
if ($oldDir !== null && (is_dir($oldDir) || is_link($oldDir))) {
$this->removeDirectory($oldDir);
}
}
private function ensureCleanDirectory(string $dir): void
{
if (is_dir($dir)) {
$this->removeDirectoryContents($dir);
rmdir($dir);
}
if (is_link($dir)) {
unlink($dir);
}
if (!mkdir($dir, 0775, true) && !is_dir($dir)) {
throw new RuntimeException('Cannot create staging directory: ' . $dir);
}
}
/**
* Return sorted top-level entry names from a module pages directory.
*
* @return list<string>
*/
private static function topLevelPageEntries(string $pagesDir): array
{
if (!is_dir($pagesDir)) {
return [];
}
$entries = scandir($pagesDir);
if ($entries === false) {
return [];
}
$entries = array_values(array_filter(
$entries,
static fn (string $e): bool => $e !== '.' && $e !== '..',
));
sort($entries);
return $entries;
}
private function removeDirectory(string $dir): void
{
if (is_link($dir)) {
unlink($dir);
return;
}
if (!is_dir($dir)) {
return;
}
$this->removeDirectoryContents($dir);
rmdir($dir);
}
private function removeDirectoryContents(string $dir): void
{
$entries = scandir($dir);

View File

@@ -0,0 +1,256 @@
<?php
namespace MintyPHP\Tests\App\Module;
use MintyPHP\App\Module\ModuleManifest;
use MintyPHP\App\Module\ModuleRuntimePageBuilder;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class ModuleRuntimePageBuilderTest extends TestCase
{
private string $tmpDir;
protected function setUp(): void
{
$this->tmpDir = sys_get_temp_dir() . '/corecore-page-builder-test-' . uniqid();
mkdir($this->tmpDir, 0777, true);
}
protected function tearDown(): void
{
$this->removePath($this->tmpDir);
}
// ── Atomic build / swap ─────────────────────────────────────────
public function testBuildCreatesRuntimeDirWithCoreAndModuleSymlinks(): void
{
$coreDir = $this->createCorePages(['admin', 'login']);
$moduleA = $this->createModuleWithPages('mod-a', ['mod-a-page']);
$runtimeDir = $this->tmpDir . '/runtime/pages';
$builder = new ModuleRuntimePageBuilder();
$result = $builder->build($runtimeDir, $coreDir, ['mod-a' => $moduleA]);
self::assertSame(2, $result['core_entries']);
self::assertSame(1, $result['module_entries']);
self::assertDirectoryExists($runtimeDir);
self::assertTrue(is_link($runtimeDir . '/admin'));
self::assertTrue(is_link($runtimeDir . '/login'));
self::assertTrue(is_link($runtimeDir . '/mod-a-page'));
}
public function testBuildWithNoModulesCreatesSymlinkToCore(): void
{
$coreDir = $this->createCorePages(['admin']);
$runtimeDir = $this->tmpDir . '/runtime/pages';
mkdir(dirname($runtimeDir), 0777, true);
$builder = new ModuleRuntimePageBuilder();
$result = $builder->build($runtimeDir, $coreDir, []);
self::assertSame(0, $result['core_entries']);
self::assertSame(0, $result['module_entries']);
self::assertTrue(is_link($runtimeDir));
}
public function testBuildFailureDoesNotLeavePartialRuntimeState(): void
{
$coreDir = $this->createCorePages(['shared-page']);
// Module has a page that collides with core — will cause RuntimeException
$moduleA = $this->createModuleWithPages('mod-a', ['shared-page']);
$runtimeDir = $this->tmpDir . '/runtime/pages';
// Pre-populate a valid runtime dir to ensure it survives the failed build
mkdir($runtimeDir, 0777, true);
file_put_contents($runtimeDir . '/sentinel.txt', 'existing');
$builder = new ModuleRuntimePageBuilder();
try {
$builder->build($runtimeDir, $coreDir, ['mod-a' => $moduleA]);
self::fail('Expected RuntimeException for page path collision');
} catch (RuntimeException $e) {
self::assertStringContainsString('collision', $e->getMessage());
}
// The existing runtime dir must still be intact — atomic build
// should not have corrupted it
self::assertDirectoryExists($runtimeDir);
self::assertFileExists($runtimeDir . '/sentinel.txt');
self::assertSame('existing', file_get_contents($runtimeDir . '/sentinel.txt'));
}
public function testBuildReplacesExistingRuntimeDir(): void
{
$coreDir = $this->createCorePages(['admin']);
$moduleA = $this->createModuleWithPages('mod-a', ['mod-page']);
$runtimeDir = $this->tmpDir . '/runtime/pages';
// First build
$builder = new ModuleRuntimePageBuilder();
$builder->build($runtimeDir, $coreDir, ['mod-a' => $moduleA]);
self::assertTrue(is_link($runtimeDir . '/mod-page'));
// Second build with different module
$moduleB = $this->createModuleWithPages('mod-b', ['mod-b-page']);
$result = $builder->build($runtimeDir, $coreDir, ['mod-b' => $moduleB]);
self::assertSame(1, $result['core_entries']);
self::assertSame(1, $result['module_entries']);
self::assertTrue(is_link($runtimeDir . '/mod-b-page'));
// Old module page should be gone
self::assertFalse(is_link($runtimeDir . '/mod-page'));
}
public function testNoStagingLeftoverOnSuccess(): void
{
$coreDir = $this->createCorePages(['admin']);
$moduleA = $this->createModuleWithPages('mod-a', ['mod-page']);
$runtimeDir = $this->tmpDir . '/runtime/pages';
$builder = new ModuleRuntimePageBuilder();
$builder->build($runtimeDir, $coreDir, ['mod-a' => $moduleA]);
// Staging dir should be cleaned up
$parentDir = dirname($runtimeDir);
$entries = scandir($parentDir);
self::assertNotFalse($entries);
$stagingEntries = array_filter(
$entries,
static fn (string $e): bool => str_starts_with($e, '.pages-staging-'),
);
self::assertEmpty($stagingEntries, 'Staging directory should be removed after successful build');
}
// ── Fingerprint ─────────────────────────────────────────────────
public function testFingerprintChangesWhenManifestContentChanges(): void
{
$moduleDir = $this->tmpDir . '/modules/mod-a';
mkdir($moduleDir, 0777, true);
file_put_contents($moduleDir . '/module.php', '<?php return ["id" => "mod-a"];');
$manifest = ModuleManifest::fromArray(['id' => 'mod-a'], $moduleDir);
$fp1 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]);
// Change manifest content
file_put_contents($moduleDir . '/module.php', '<?php return ["id" => "mod-a", "version" => "2.0.0"];');
$fp2 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]);
self::assertNotSame($fp1, $fp2, 'Fingerprint must change when manifest file content changes');
}
public function testFingerprintChangesWhenPageEntriesChange(): void
{
$moduleDir = $this->tmpDir . '/modules/mod-a';
mkdir($moduleDir . '/pages', 0777, true);
file_put_contents($moduleDir . '/module.php', '<?php return ["id" => "mod-a"];');
file_put_contents($moduleDir . '/pages/page-a.php', '<?php');
$manifest = ModuleManifest::fromArray(['id' => 'mod-a'], $moduleDir);
$fp1 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]);
// Add a new page entry
file_put_contents($moduleDir . '/pages/page-b.php', '<?php');
$fp2 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]);
self::assertNotSame($fp1, $fp2, 'Fingerprint must change when page entries change');
}
public function testFingerprintStableForSameInputs(): void
{
$moduleDir = $this->tmpDir . '/modules/mod-a';
mkdir($moduleDir . '/pages', 0777, true);
file_put_contents($moduleDir . '/module.php', '<?php return ["id" => "mod-a"];');
file_put_contents($moduleDir . '/pages/page-a.php', '<?php');
$manifest = ModuleManifest::fromArray(['id' => 'mod-a'], $moduleDir);
$fp1 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]);
$fp2 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]);
self::assertSame($fp1, $fp2, 'Fingerprint must be deterministic for same inputs');
}
public function testFingerprintEmptyForNoModules(): void
{
$fp = ModuleRuntimePageBuilder::expectedFingerprint([]);
self::assertSame('', $fp);
}
public function testWriteAndReadFingerprintRoundtrip(): void
{
$runtimeDir = $this->tmpDir . '/runtime/pages';
mkdir(dirname($runtimeDir), 0777, true);
$moduleDir = $this->tmpDir . '/modules/mod-a';
mkdir($moduleDir, 0777, true);
file_put_contents($moduleDir . '/module.php', '<?php return ["id" => "mod-a"];');
$manifest = ModuleManifest::fromArray(['id' => 'mod-a'], $moduleDir);
$modules = ['mod-a' => $manifest];
ModuleRuntimePageBuilder::writeFingerprint($runtimeDir, $modules);
$expected = ModuleRuntimePageBuilder::expectedFingerprint($modules);
$actual = ModuleRuntimePageBuilder::readFingerprint($runtimeDir);
self::assertSame($expected, $actual);
}
public function testReadFingerprintReturnsEmptyWhenFileDoesNotExist(): void
{
$runtimeDir = $this->tmpDir . '/runtime/pages';
self::assertSame('', ModuleRuntimePageBuilder::readFingerprint($runtimeDir));
}
// ── Helpers ──────────────────────────────────────────────────────
private function createCorePages(array $entries): string
{
$coreDir = $this->tmpDir . '/pages';
mkdir($coreDir, 0777, true);
foreach ($entries as $entry) {
mkdir($coreDir . '/' . $entry, 0777, true);
}
return $coreDir;
}
private function createModuleWithPages(string $id, array $pageEntries): ModuleManifest
{
$moduleDir = $this->tmpDir . '/modules/' . $id;
mkdir($moduleDir . '/pages', 0777, true);
file_put_contents($moduleDir . '/module.php', '<?php return ' . var_export(['id' => $id], true) . ';');
foreach ($pageEntries as $entry) {
mkdir($moduleDir . '/pages/' . $entry, 0777, true);
}
return ModuleManifest::fromArray(['id' => $id], $moduleDir);
}
private function removePath(string $path): void
{
if (!file_exists($path) && !is_link($path)) {
return;
}
if (is_link($path) || is_file($path)) {
@unlink($path);
return;
}
if (!is_dir($path)) {
return;
}
$entries = scandir($path);
if ($entries !== false) {
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$this->removePath($path . '/' . $entry);
}
}
@rmdir($path);
}
}

View File

@@ -38,7 +38,7 @@
.app-breadcrumb li + li::before {
content: "/";
color: var(--app-muted-color);
font-size: 0.75em;
font-size: 0.75em; /* em-relative sizing — intentional */
margin-inline-end: 0.375rem;
pointer-events: none;
}
@@ -68,7 +68,7 @@
.app-breadcrumb [aria-current="page"] {
color: var(--app-color);
font-weight: 500;
font-weight: var(--font-medium);
pointer-events: none;
}
}