From 5d07236758af199e166d5f89052ddadc46a5ac71 Mon Sep 17 00:00:00 2001 From: fs Date: Thu, 2 Apr 2026 10:49:35 +0200 Subject: [PATCH] feat(search): refresh global search UI and align qa/docs updates --- .agents/checks/quality-gates.json | 2 +- .../references/quality-gates.md | 5 +- CLAUDE.md | 3 +- README.md | 15 ++- bin/docs-drift-check.sh | 56 +++++++++ bin/qa-required.sh | 3 + db/init/init.sql | 9 ++ docs/howto-docker-lokal.md | 16 ++- docs/howto-gitea-required-checks.md | 2 +- docs/howto-globale-suche.md | 7 +- docs/howto-lokale-entwicklung.md | 10 ++ docs/reference-cli-commands.md | 2 + docs/reference-entwickler-checkliste.md | 3 +- docs/reference-konfiguration.md | 4 +- docs/tutorial-01-erste-schritte.md | 4 +- docs/tutorial-03-setup-und-erster-login.md | 3 +- .../Search/SearchItemMapperProvider.php | 25 +++- lib/Support/SearchConfig.php | 9 +- .../Providers/AuditSearchResourceProvider.php | 32 +++-- .../AuditSearchResourceProviderTest.php | 9 +- templates/partials/app-search-dialog.phtml | 17 ++- web/css/base/variables.base.css | 39 +++--- web/css/base/variables.contrast.css | 4 +- web/css/components/app-dialog.css | 30 ++++- web/css/components/app-list-tabs.css | 6 +- web/css/components/app-search.css | 110 ++++++++-------- web/css/components/app-tabs.css | 12 +- web/css/components/app-tooltips.css | 32 ++--- web/css/layout/app-shell.css | 2 +- web/css/layout/app-sidebar.css | 5 +- web/css/layout/app-topbar.css | 108 ++++++++++++---- web/css/pages/app-error.css | 12 +- web/css/pages/app-login.css | 2 +- web/css/vendor-overrides/editorjs.css | 2 +- web/js/components/app-global-search.js | 117 +++++++++++++----- 35 files changed, 504 insertions(+), 213 deletions(-) create mode 100755 bin/docs-drift-check.sh diff --git a/.agents/checks/quality-gates.json b/.agents/checks/quality-gates.json index c968da8..1b540c9 100644 --- a/.agents/checks/quality-gates.json +++ b/.agents/checks/quality-gates.json @@ -50,7 +50,7 @@ "type": "fast", "name": "Docs link integrity", "command": "bin/docs-link-check.sh", - "note": "Default scanner excludes .agents/runs/** to avoid historical artifact false positives." + "note": "Default scanner excludes .agents/runs/** to avoid historical artifact false positives. qa-required also enforces bin/docs-drift-check.sh as QG-008 supplement." }, { "id": "QG-009", diff --git a/.agents/skills/core-guardrails/references/quality-gates.md b/.agents/skills/core-guardrails/references/quality-gates.md index d1db799..4006308 100644 --- a/.agents/skills/core-guardrails/references/quality-gates.md +++ b/.agents/skills/core-guardrails/references/quality-gates.md @@ -37,7 +37,10 @@ Erwartung: jeweils `0`. 1. `bin/docs-link-check.sh` - prueft Doku-Links in `README.md`, `docs/**`, `.agents/skills/**` und `.agents/**` - `.agents/runs/**` ist standardmaessig ausgeschlossen (historische Artefakte) -2. `bin/codex-skills-sync.sh --check` +2. `bin/docs-drift-check.sh` + - prueft bekannte Legacy-Drifts (entfernte Config-Pfade/Constants, obsolete Search-Marker) + - prueft Setup-Vertrag fuer `config/config.php.example` in den Kern-Onboarding-Dokus +3. `bin/codex-skills-sync.sh --check` - prueft Drift zwischen Repo-Skills und `~/.codex/skills` ## Berichtspflicht diff --git a/CLAUDE.md b/CLAUDE.md index a7f8341..c782956 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,7 @@ Full workflow definition: `.agents/workflow.md` | Guard catalog: `.agents/checks ```bash # Start dev environment +cp config/config.php.example config/config.php docker compose up --build -d # App: http://localhost:8080 | phpMyAdmin: http://localhost:8081 @@ -212,7 +213,7 @@ Required/optional enforcement is defined in `.agents/checks/enforcement-policy.j | 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 | `vendor/bin/php-cs-fixer fix --config=tools/php-cs-fixer/.php-cs-fixer.dist.php --dry-run --diff --verbose` | -| QG-008 | Docs link integrity | `bin/docs-link-check.sh` | +| QG-008 | Docs link + drift integrity | `bin/docs-link-check.sh` (plus `bin/docs-drift-check.sh` via `bin/qa-required.sh`) | | QG-009 | Codex skills sync | `bin/codex-skills-sync.sh --check` | Additional non-blocking gates: QG-004 structural checks, QG-005 JS smoke, QG-007 composer-unused. See gate catalog + enforcement policy for details. diff --git a/README.md b/README.md index 7e77e25..346c7fe 100644 --- a/README.md +++ b/README.md @@ -35,18 +35,24 @@ Der Fokus liegt auf klarer Rollen-/Rechtestruktur, Tenant-Scope und einer konsis cp .env.example .env ``` -2. Container starten +2. Config-Template fuer den App-Bootstrap anlegen + +```bash +cp config/config.php.example config/config.php +``` + +3. Container starten ```bash docker compose up --build -d ``` -3. App oeffnen +4. App oeffnen - App: [http://localhost:8080](http://localhost:8080) - phpMyAdmin: [http://localhost:8081](http://localhost:8081) -4. Container stoppen +5. Container stoppen ```bash docker compose down @@ -55,6 +61,7 @@ docker compose down Alternativ ueber den Dev-Orchestrator: ```bash +cp config/config.php.example config/config.php bin/dev init bin/dev up bin/dev down @@ -122,7 +129,7 @@ Initiale DB-Settings (Seed): ## Projektstruktur -- `/config` Konfiguration (Routen, Settings, Themes, Assets) +- `/config` Konfiguration (Routen, Assets, Themes, Module + ENV-Bootstrap) - `/db/init` Datenbankschema + Seeds - `/lib` Backend-Code (`Repository`, `Service`, `Http`, `Support`) - `/pages` Action-Layer + Views pro Route diff --git a/bin/docs-drift-check.sh b/bin/docs-drift-check.sh new file mode 100755 index 0000000..c4e6a20 --- /dev/null +++ b/bin/docs-drift-check.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd -- "${script_dir}/.." && pwd)" +cd "${repo_root}" + +scan_targets=(README.md CLAUDE.md docs .agents) +scan_globs=(-g '*.md' -g '*.json' -g '!.agents/runs/**') + +fail=0 + +check_legacy_pattern() { + local pattern="$1" + local label="$2" + local matches + matches="$(rg -n --pcre2 "${pattern}" "${scan_targets[@]}" "${scan_globs[@]}" || true)" + if [[ -n "${matches}" ]]; then + echo "[FAIL] ${label}" + echo "${matches}" + fail=1 + fi +} + +check_legacy_pattern 'config/settings\.php' 'Legacy docs reference to config/settings.php found' +check_legacy_pattern 'config/router\.php' 'Legacy docs reference to config/router.php found' +check_legacy_pattern '\bAPP_ROUTE_DEFINITIONS\b' 'Legacy docs reference to APP_ROUTE_DEFINITIONS found' +check_legacy_pattern '\bAPP_PUBLIC_PATHS\b' 'Legacy docs reference to APP_PUBLIC_PATHS found' +check_legacy_pattern 'data-search-key' 'Legacy global-search aside marker (data-search-key) found' + +if [[ ! -f config/config.php.example ]]; then + echo "[FAIL] Missing setup template: config/config.php.example" + fail=1 +fi + +required_setup_docs=( + README.md + docs/howto-docker-lokal.md + docs/tutorial-01-erste-schritte.md + docs/tutorial-03-setup-und-erster-login.md + docs/howto-lokale-entwicklung.md +) + +for doc in "${required_setup_docs[@]}"; do + if ! rg -q 'config/config\.php\.example' "${doc}"; then + echo "[FAIL] Missing setup contract reference in ${doc}: config/config.php.example" + fail=1 + fi +done + +if [[ ${fail} -ne 0 ]]; then + echo "[FAIL] docs drift checks failed" + exit 1 +fi + +echo "[OK] docs drift checks passed" diff --git a/bin/qa-required.sh b/bin/qa-required.sh index 2c35e7b..9d264f3 100755 --- a/bin/qa-required.sh +++ b/bin/qa-required.sh @@ -38,6 +38,9 @@ run_step "QG-006 PHP Style Check" \ run_step "QG-008 Docs link integrity" \ bin/docs-link-check.sh +run_step "QG-008 Docs drift contract" \ + bin/docs-drift-check.sh + run_step "QG-009 Codex skills sync" \ bin/codex-skills-sync.sh --check diff --git a/db/init/init.sql b/db/init/init.sql index 6546ca4..a09bd40 100644 --- a/db/init/init.sql +++ b/db/init/init.sql @@ -456,6 +456,15 @@ CREATE TABLE IF NOT EXISTS `user_custom_field_value_options` ( CONSTRAINT `fk_ucfvo_option` FOREIGN KEY (`option_id`) REFERENCES `tenant_custom_field_options` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `module_migrations` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `module_id` VARCHAR(64) NOT NULL, + `filename` VARCHAR(255) NOT NULL, + `applied_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_module_migration` (`module_id`, `filename`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `user_api_tokens` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `uuid` CHAR(36) NOT NULL, diff --git a/docs/howto-docker-lokal.md b/docs/howto-docker-lokal.md index bce705a..54d5882 100644 --- a/docs/howto-docker-lokal.md +++ b/docs/howto-docker-lokal.md @@ -1,6 +1,6 @@ # Docker lokal -Letzte Aktualisierung: 2026-03-06 +Letzte Aktualisierung: 2026-04-01 ## Ziel @@ -11,7 +11,7 @@ Diese Anleitung bringt die Anwendung lokal schnell und stabil zum Laufen. - Docker Desktop (oder Docker Engine + Compose) - Port `8080` (App) und `8081` (phpMyAdmin) sind frei -## Start in 4 Schritten +## Start in 5 Schritten 1. ENV-Datei anlegen: @@ -19,17 +19,23 @@ Diese Anleitung bringt die Anwendung lokal schnell und stabil zum Laufen. cp .env.example .env ``` -2. Container bauen und starten: +2. Config-Template fuer den App-Bootstrap anlegen: + +```bash +cp config/config.php.example config/config.php +``` + +3. Container bauen und starten: ```bash docker compose up --build -d ``` -3. Anwendung öffnen: +4. Anwendung öffnen: - App: `http://localhost:8080` - phpMyAdmin: `http://localhost:8081` -4. Logs prüfen (optional): +5. Logs prüfen (optional): ```bash docker compose logs -f nginx php diff --git a/docs/howto-gitea-required-checks.md b/docs/howto-gitea-required-checks.md index 62674d0..7eac6d7 100644 --- a/docs/howto-gitea-required-checks.md +++ b/docs/howto-gitea-required-checks.md @@ -20,7 +20,7 @@ QA-Pflichtchecks fuer CoreCore in Gitea vorbereiten, ohne sie sofort als Merge-B - `QG-002` PHPStan - `QG-003` Architecture Core Contract - `QG-006` PHP Style Check -- `QG-008` Docs link integrity +- `QG-008` Docs link integrity + Drift-Contract (`docs-link-check` + `docs-drift-check`) - `QG-009` Codex skills sync ## Phase-1 Setup (noch nicht blockend) diff --git a/docs/howto-globale-suche.md b/docs/howto-globale-suche.md index d5b1fa7..e278468 100644 --- a/docs/howto-globale-suche.md +++ b/docs/howto-globale-suche.md @@ -1,6 +1,6 @@ # Globale Suche erweitern -Letzte Aktualisierung: 2026-03-06 +Letzte Aktualisierung: 2026-04-01 ## Ziel @@ -25,6 +25,7 @@ Provider: - UI-Meta (Icons/URLs): `SearchUiMetaProvider` - Mapping: `SearchItemMapperProvider` - Spezialquellen (`docs`, `hotkeys`): `SearchSpecialResourceProvider` +- Modul-Erweiterungen: `SearchResourceProvider` via Modul-Manifest `search_resources` ## Laufzeitpfad @@ -45,8 +46,8 @@ Provider: 4. Mapping ergänzen: - `mapPreviewItem()` - `mapResultItem()` -5. Aside-Eintrag in `templates/partials/app-main-aside.phtml` ergänzen (`data-search-key`). -6. i18n-Labels prüfen. +5. Bei Modul-Resources: Provider-Klasse in `module.php` unter `search_resources` registrieren. +6. i18n-Labels pruefen. ## Wichtige Regeln diff --git a/docs/howto-lokale-entwicklung.md b/docs/howto-lokale-entwicklung.md index dbfdddf..ada1755 100644 --- a/docs/howto-lokale-entwicklung.md +++ b/docs/howto-lokale-entwicklung.md @@ -8,6 +8,14 @@ Kompakter Tagesworkflow für Entwicklung, Tests und schema-nahe Änderungen. ## First-Time Workflow +Einmalig vor dem ersten Start: + +```bash +cp config/config.php.example config/config.php +``` + +Dann: + ```bash bin/dev init ``` @@ -19,6 +27,8 @@ bin/dev init 3. `module:sync` 4. `doctor` +Hinweis: `bin/dev init` erzeugt aktuell **nicht** automatisch `config/config.php`. + ## Start/Stop ```bash diff --git a/docs/reference-cli-commands.md b/docs/reference-cli-commands.md index ebd3b69..13b00ea 100644 --- a/docs/reference-cli-commands.md +++ b/docs/reference-cli-commands.md @@ -147,6 +147,8 @@ bin/dev qa-required bin/dev qa-extended ``` +Voraussetzung fuer den ersten App-Start: `config/config.php` muss vorhanden sein (einmalig aus `config/config.php.example` kopieren). + `bin/dev console ...` delegiert intern auf `docker compose exec -T php php bin/console ...`. `bin/dev qa` ist ein Alias fuer `bin/dev qa-required`. diff --git a/docs/reference-entwickler-checkliste.md b/docs/reference-entwickler-checkliste.md index 3cd8dd4..6bfefa2 100644 --- a/docs/reference-entwickler-checkliste.md +++ b/docs/reference-entwickler-checkliste.md @@ -1,6 +1,6 @@ # Entwickler-Checkliste -Letzte Aktualisierung: 2026-03-24 +Letzte Aktualisierung: 2026-04-01 ## Ziel @@ -79,6 +79,7 @@ Kurze Definition of Done vor Merge. - [ ] Keine First-party CLI-Warnings/Notices/Deprecations im Runtime-Sync (Vendor-Warnings sind nur temporaer toleriert und muessen gezaehlt sichtbar sein) - [ ] bei Docs-/Skills-Aenderungen: `docker compose exec php vendor/bin/phpunit tests/Architecture/CodexSkillReferenceContractTest.php` - [ ] `bin/docs-link-check.sh` (QG-008, scannt standardmaessig ohne `.agents/runs/**`) +- [ ] `bin/docs-drift-check.sh` (QG-008-Zusatz, blockt Legacy-Referenzen + fehlenden Setup-Vertrag) - [ ] `bin/codex-skills-sync.sh --check` (QG-009) - [ ] bei JS-Änderungen: Browser-Smoke mit DevTools-Console (keine JS-Errors) - [ ] bei API-Änderungen: `docs/openapi.yaml` aktualisiert diff --git a/docs/reference-konfiguration.md b/docs/reference-konfiguration.md index 5a46631..b9f2878 100644 --- a/docs/reference-konfiguration.md +++ b/docs/reference-konfiguration.md @@ -1,10 +1,12 @@ # Konfiguration (ENV-Variablen) -Letzte Aktualisierung: 2026-03-25 +Letzte Aktualisierung: 2026-04-01 Alle Konfiguration erfolgt über Umgebungsvariablen in der `.env`-Datei. Vorlage: `.env.example` — niemals echte Credentials committen. +Setup-Vertrag (Source of Truth): Fuer den Boot wird `config/config.php` benoetigt; die Datei wird aus `config/config.php.example` abgeleitet. Diese Seite und `web/index.php` sind die massgeblichen Referenzen dafuer. + --- ## App diff --git a/docs/tutorial-01-erste-schritte.md b/docs/tutorial-01-erste-schritte.md index 4beb569..fe20d94 100644 --- a/docs/tutorial-01-erste-schritte.md +++ b/docs/tutorial-01-erste-schritte.md @@ -1,6 +1,6 @@ # Erste Schritte -Letzte Aktualisierung: 2026-03-06 +Letzte Aktualisierung: 2026-04-01 ## Ziel @@ -16,10 +16,12 @@ Diese Seite bringt neue Entwickler in unter 45 Minuten zu einem lauffähigen lok ```bash cp .env.example .env +cp config/config.php.example config/config.php docker compose up --build -d ``` > Alle ENV-Variablen sind dokumentiert in /docs/reference-konfiguration.md. +> `config/config.php` ist Pflicht fuer den App-Bootstrap und wird einmalig aus `config/config.php.example` erzeugt. > docker compose up startet auch den globalen Scheduler-Runner (Service scheduler) im Hintergrund. Danach erreichbar: diff --git a/docs/tutorial-03-setup-und-erster-login.md b/docs/tutorial-03-setup-und-erster-login.md index 1327677..28ffdce 100644 --- a/docs/tutorial-03-setup-und-erster-login.md +++ b/docs/tutorial-03-setup-und-erster-login.md @@ -1,6 +1,6 @@ # Setup und erster Login -Letzte Aktualisierung: 2026-03-06 +Letzte Aktualisierung: 2026-04-01 ## Ziel @@ -17,6 +17,7 @@ Lokal starten, einloggen, Basisfunktion prüfen. ```bash cp .env.example .env +cp config/config.php.example config/config.php docker compose up --build -d ``` diff --git a/lib/Support/Search/SearchItemMapperProvider.php b/lib/Support/Search/SearchItemMapperProvider.php index e3cff5b..a753065 100644 --- a/lib/Support/Search/SearchItemMapperProvider.php +++ b/lib/Support/Search/SearchItemMapperProvider.php @@ -6,11 +6,14 @@ class SearchItemMapperProvider { public static function mapPreviewItem(string $key, array $data, string $query): ?array { + $subtitle = ''; + if ($key === 'users') { $label = trim(($data['first_name'] ?? '') . ' ' . ($data['last_name'] ?? '')); - $email = trim((string) ($data['email'] ?? '')); - if ($email !== '') { - $label = $label === '' ? $email : ($label . ' — ' . $email); + $subtitle = trim((string) ($data['email'] ?? '')); + if ($label === '' && $subtitle !== '') { + $label = $subtitle; + $subtitle = ''; } $url = lurl('admin/users/edit/' . ($data['uuid'] ?? '')) . '?search=' . urlencode($query); } elseif ($key === 'permissions') { @@ -22,7 +25,7 @@ class SearchItemMapperProvider $status = trim((string) ($data['last_run_status'] ?? '')); $label = $jobLabel !== '' ? $jobLabel : $jobKey; if ($status !== '') { - $label = trim($label . ' — ' . strtoupper($status)); + $subtitle = strtoupper($status); } $url = lurl('admin/scheduled-jobs/edit/' . ($data['id'] ?? '')) . '?search=' . urlencode($query); } elseif ($key === 'mail-log') { @@ -30,9 +33,14 @@ class SearchItemMapperProvider $subject = trim((string) ($data['subject'] ?? '')); $status = trim((string) ($data['status'] ?? '')); $label = $subject !== '' ? $subject : $toEmail; + $subtitleParts = []; if ($status !== '') { - $label = trim($label . ' — ' . strtoupper($status)); + $subtitleParts[] = strtoupper($status); } + if ($toEmail !== '' && $subject !== '') { + $subtitleParts[] = $toEmail; + } + $subtitle = implode(' — ', $subtitleParts); $url = lurl('admin/mail-log/view/' . ($data['id'] ?? '')) . '?search=' . urlencode($query); } elseif ($key === 'pages') { $label = (string) ($data['slug'] ?? ''); @@ -46,7 +54,12 @@ class SearchItemMapperProvider return null; } - return ['label' => $label, 'url' => $url]; + $item = ['label' => $label, 'url' => $url]; + if ($subtitle !== '') { + $item['subtitle'] = $subtitle; + } + + return $item; } public static function mapResultItem(string $key, string $label, array $data): ?array diff --git a/lib/Support/SearchConfig.php b/lib/Support/SearchConfig.php index d7aa8aa..f5102e2 100644 --- a/lib/Support/SearchConfig.php +++ b/lib/Support/SearchConfig.php @@ -106,7 +106,14 @@ class SearchConfig if ($provider !== null) { $mapped = $provider->mapResultItem($key, $data); if ($mapped !== null) { - return ['label' => $mapped['title'], 'url' => $mapped['url']]; + $item = ['label' => $mapped['title'], 'url' => $mapped['url']]; + if (isset($mapped['subtitle']) && $mapped['subtitle'] !== '') { + $item['subtitle'] = $mapped['subtitle']; + } + if (isset($mapped['icon']) && $mapped['icon'] !== '') { + $item['icon'] = $mapped['icon']; + } + return $item; } return null; } diff --git a/modules/audit/lib/Module/Audit/Providers/AuditSearchResourceProvider.php b/modules/audit/lib/Module/Audit/Providers/AuditSearchResourceProvider.php index 26f6376..d10027f 100644 --- a/modules/audit/lib/Module/Audit/Providers/AuditSearchResourceProvider.php +++ b/modules/audit/lib/Module/Audit/Providers/AuditSearchResourceProvider.php @@ -14,14 +14,14 @@ final class AuditSearchResourceProvider implements SearchResourceProvider return [ 'api-audit' => [ 'label' => t('API audit'), - 'permission' => 'api_audit.view', + 'permission' => 'audit.api.view', 'count_sql' => "select count(*) from api_audit_log where (request_id like ? escape '\\\\' or path like ? escape '\\\\' or error_code like ? escape '\\\\' or ip like ? escape '\\\\')", 'preview_sql' => "select id, request_id, method, path, status_code, created_at from api_audit_log where (request_id like ? escape '\\\\' or path like ? escape '\\\\' or error_code like ? escape '\\\\' or ip like ? escape '\\\\') order by created_at desc limit ?", 'result_sql' => "select id, request_id, method, path, status_code, created_at from api_audit_log where (request_id like ? escape '\\\\' or path like ? escape '\\\\' or error_code like ? escape '\\\\' or ip like ? escape '\\\\') order by created_at desc", ], 'system-audit' => [ 'label' => t('System audit'), - 'permission' => 'system_audit.view', + 'permission' => 'audit.system.view', 'count_sql' => "select count(*) from system_audit_log where (request_id like ? escape '\\\\' or event_type like ? escape '\\\\' or error_code like ? escape '\\\\' or path like ? escape '\\\\')", 'preview_sql' => "select id, request_id, event_type, outcome, created_at from system_audit_log where (request_id like ? escape '\\\\' or event_type like ? escape '\\\\' or error_code like ? escape '\\\\' or path like ? escape '\\\\') order by created_at desc limit ?", 'result_sql' => "select id, request_id, event_type, outcome, created_at from system_audit_log where (request_id like ? escape '\\\\' or event_type like ? escape '\\\\' or error_code like ? escape '\\\\' or path like ? escape '\\\\') order by created_at desc", @@ -60,21 +60,27 @@ final class AuditSearchResourceProvider implements SearchResourceProvider $method = strtoupper(trim((string) ($row['method'] ?? ''))); $path = trim((string) ($row['path'] ?? '')); $statusCode = (int) ($row['status_code'] ?? 0); - $requestId = trim((string) ($row['request_id'] ?? '')); + $createdAt = trim((string) ($row['created_at'] ?? '')); $title = trim($method . ' ' . $path); if ($title === '') { - $title = $requestId; + $title = trim((string) ($row['request_id'] ?? '')); } if ($title === '') { return null; } - $subtitle = trim(($statusCode > 0 ? (string) $statusCode : '') . ($requestId !== '' ? ' — ' . $requestId : '')); + $subtitleParts = []; + if ($statusCode > 0) { + $subtitleParts[] = (string) $statusCode; + } + if ($createdAt !== '') { + $subtitleParts[] = $createdAt; + } return [ 'title' => $title, - 'subtitle' => $subtitle, + 'subtitle' => implode(' — ', $subtitleParts), 'url' => lurl('admin/api-audit/view/' . ($row['id'] ?? '')), 'icon' => 'bi-shield-check', ]; @@ -88,18 +94,24 @@ final class AuditSearchResourceProvider implements SearchResourceProvider { $eventType = trim((string) ($row['event_type'] ?? '')); $outcome = strtolower(trim((string) ($row['outcome'] ?? ''))); - $requestId = trim((string) ($row['request_id'] ?? '')); + $createdAt = trim((string) ($row['created_at'] ?? '')); - $title = $eventType !== '' ? $eventType : $requestId; + $title = $eventType !== '' ? $eventType : trim((string) ($row['request_id'] ?? '')); if ($title === '') { return null; } - $subtitle = trim(($outcome !== '' ? strtoupper($outcome) : '') . ($requestId !== '' ? ' — ' . $requestId : '')); + $subtitleParts = []; + if ($outcome !== '') { + $subtitleParts[] = strtoupper($outcome); + } + if ($createdAt !== '') { + $subtitleParts[] = $createdAt; + } return [ 'title' => $title, - 'subtitle' => $subtitle, + 'subtitle' => implode(' — ', $subtitleParts), 'url' => lurl('admin/system-audit/view/' . ($row['id'] ?? '')), 'icon' => 'bi-journal-lock', ]; diff --git a/modules/audit/tests/Module/Audit/Providers/AuditSearchResourceProviderTest.php b/modules/audit/tests/Module/Audit/Providers/AuditSearchResourceProviderTest.php index f033046..4b45aa4 100644 --- a/modules/audit/tests/Module/Audit/Providers/AuditSearchResourceProviderTest.php +++ b/modules/audit/tests/Module/Audit/Providers/AuditSearchResourceProviderTest.php @@ -38,8 +38,8 @@ class AuditSearchResourceProviderTest extends TestCase { $resources = $this->provider->resources(); - $this->assertSame('api_audit.view', $resources['api-audit']['permission']); - $this->assertSame('system_audit.view', $resources['system-audit']['permission']); + $this->assertSame('audit.api.view', $resources['api-audit']['permission']); + $this->assertSame('audit.system.view', $resources['system-audit']['permission']); } public function testMapApiAuditItem(): void @@ -50,6 +50,7 @@ class AuditSearchResourceProviderTest extends TestCase 'path' => '/api/v1/users', 'status_code' => 201, 'request_id' => 'req-abc', + 'created_at' => '2026-03-15 10:30:00', ]; $result = $this->provider->mapResultItem('api-audit', $row); @@ -57,7 +58,7 @@ class AuditSearchResourceProviderTest extends TestCase $this->assertNotNull($result); $this->assertSame('POST /api/v1/users', $result['title']); $this->assertStringContains('201', $result['subtitle']); - $this->assertStringContains('req-abc', $result['subtitle']); + $this->assertStringContains('2026-03-15 10:30:00', $result['subtitle']); $this->assertSame('bi-shield-check', $result['icon']); $this->assertStringContainsString('admin/api-audit/view/42', $result['url']); } @@ -88,6 +89,7 @@ class AuditSearchResourceProviderTest extends TestCase 'event_type' => 'admin.settings.update', 'outcome' => 'success', 'request_id' => 'req-xyz', + 'created_at' => '2026-03-15 11:00:00', ]; $result = $this->provider->mapResultItem('system-audit', $row); @@ -95,6 +97,7 @@ class AuditSearchResourceProviderTest extends TestCase $this->assertNotNull($result); $this->assertSame('admin.settings.update', $result['title']); $this->assertStringContains('SUCCESS', $result['subtitle']); + $this->assertStringContains('2026-03-15 11:00:00', $result['subtitle']); $this->assertSame('bi-journal-lock', $result['icon']); $this->assertStringContainsString('admin/system-audit/view/99', $result['url']); } diff --git a/templates/partials/app-search-dialog.phtml b/templates/partials/app-search-dialog.phtml index dab6d7e..21bb79c 100644 --- a/templates/partials/app-search-dialog.phtml +++ b/templates/partials/app-search-dialog.phtml @@ -11,11 +11,10 @@ data-app-component="global-search" data-search-endpoint="" data-search-results-page="" - aria-labelledby="app-search-dialog-title" + aria-label="" >
-

-
-

- +

    - - Esc +
diff --git a/web/css/base/variables.base.css b/web/css/base/variables.base.css index 58be7eb..88310ee 100644 --- a/web/css/base/variables.base.css +++ b/web/css/base/variables.base.css @@ -57,18 +57,18 @@ --app-blockquote-background-color: var( --app-form-element-active-background-color ); - --list-tabs-border: var(--app-border); - --list-tabs-active: var(--app-primary); - --list-tabs-muted: var(--app-muted-color); - --tabs-border: var(--app-border); - --tabs-active: var(--app-primary); - --tabs-muted: var(--app-muted-color); - --tooltip-bg: var(--app-card-background-color); - --tooltip-color: var(--app-color); - --tooltip-border: var(--app-border); - --tooltip-shadow: 0 6px 18px rgba(0, 0, 0, 0.12); - --tooltip-radius: 8px; - --tooltip-offset: 8px; + --app-list-tabs-border: var(--app-border); + --app-list-tabs-active: var(--app-primary); + --app-list-tabs-muted: var(--app-muted-color); + --app-tabs-border: var(--app-border); + --app-tabs-active: var(--app-primary); + --app-tabs-muted: var(--app-muted-color); + --app-tooltip-bg: var(--app-card-background-color); + --app-tooltip-color: var(--app-color); + --app-tooltip-border: var(--app-border); + --app-tooltip-shadow: 0 6px 18px rgba(0, 0, 0, 0.12); + --app-tooltip-radius: 8px; + --app-tooltip-offset: 8px; --app-badge-background-color: coral; --app-empty-border-color: var(--app-muted-border-color); --app-empty-bg: var(--app-card-background-color); @@ -239,6 +239,7 @@ --app-form-element-active-border-color: var(--app-primary-border); --app-form-element-focus-color: var(--app-primary-border); --app-form-element-disabled-opacity: 0.5; + --app-form-element-disabled-background-color: #f0f2f5; --app-form-element-readonly-background-color: #f4f7fb; --app-form-element-readonly-border-color: #d6deea; --app-form-element-readonly-color: #2f3440; @@ -248,6 +249,8 @@ --app-form-element-invalid-focus-color: var( --app-form-element-invalid-active-border-color ); + --app-form-element-invalid-active-shadow: 0 0 0 var(--app-outline-width) + var(--app-form-element-invalid-focus-color); --app-form-element-valid-border-color: rgb(76, 154.5, 137.5); --app-form-element-valid-active-border-color: rgb(39, 152.75, 118.75); --app-form-element-valid-focus-color: var( @@ -306,8 +309,6 @@ --app-modal-overlay-background-color: rgba(232, 234, 237, 0.75); --app-progress-background-color: #dfe3eb; --app-progress-color: var(--app-primary-background); - --app-tooltip-background-color: var(--app-contrast-background); - --app-tooltip-color: var(--app-contrast-inverse); --app-icon-valid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(76, 154.5, 137.5)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E"); --app-icon-invalid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(200.25, 79.25, 72.25)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E"); } @@ -499,6 +500,7 @@ --app-form-element-active-border-color: var(--app-primary-border); --app-form-element-focus-color: var(--app-primary-border); --app-form-element-disabled-opacity: 0.5; + --app-form-element-disabled-background-color: #1e2430; --app-form-element-readonly-background-color: #242c3a; --app-form-element-readonly-border-color: #3a4458; --app-form-element-readonly-color: #d7dde8; @@ -508,6 +510,8 @@ --app-form-element-invalid-focus-color: var( --app-form-element-invalid-active-border-color ); + --app-form-element-invalid-active-shadow: 0 0 0 var(--app-outline-width) + var(--app-form-element-invalid-focus-color); --app-form-element-valid-border-color: #2a7b6f; --app-form-element-valid-active-border-color: rgb(22, 137, 105.5); --app-form-element-valid-focus-color: var( @@ -554,8 +558,6 @@ --app-modal-overlay-background-color: rgba(7.5, 8.5, 10, 0.75); --app-progress-background-color: #202632; --app-progress-color: var(--app-primary-background); - --app-tooltip-background-color: var(--app-contrast-background); - --app-tooltip-color: var(--app-contrast-inverse); --app-icon-valid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E"); --app-icon-invalid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E"); } @@ -736,6 +738,7 @@ --app-form-element-active-border-color: var(--app-primary-border); --app-form-element-focus-color: var(--app-primary-border); --app-form-element-disabled-opacity: 0.5; + --app-form-element-disabled-background-color: #1e2430; --app-form-element-readonly-background-color: #242c3a; --app-form-element-readonly-border-color: #3a4458; --app-form-element-readonly-color: #d7dde8; @@ -745,6 +748,8 @@ --app-form-element-invalid-focus-color: var( --app-form-element-invalid-active-border-color ); + --app-form-element-invalid-active-shadow: 0 0 0 var(--app-outline-width) + var(--app-form-element-invalid-focus-color); --app-form-element-valid-border-color: #2a7b6f; --app-form-element-valid-active-border-color: rgb(22, 137, 105.5); --app-form-element-valid-focus-color: var( @@ -803,8 +808,6 @@ --app-modal-overlay-background-color: rgba(7.5, 8.5, 10, 0.75); --app-progress-background-color: #202632; --app-progress-color: var(--app-primary-background); - --app-tooltip-background-color: var(--app-contrast-background); - --app-tooltip-color: var(--app-contrast-inverse); --app-icon-valid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E"); --app-icon-invalid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E"); } diff --git a/web/css/base/variables.contrast.css b/web/css/base/variables.contrast.css index 6b16da2..2e78561 100644 --- a/web/css/base/variables.contrast.css +++ b/web/css/base/variables.contrast.css @@ -19,7 +19,7 @@ html[data-contrast="high"][data-theme="light"], --app-accordion-border-color: white; --app-card-background-color: #ffffff; --app-blockquote-border-color: var(--app-muted-border-color); - --app-tooltip-background-color: #000; + --app-tooltip-bg: #000; --app-tooltip-color: #fff; --app-action-success-background: #0f5f00; --app-action-success-border: #0f5f00; @@ -54,7 +54,7 @@ html[data-contrast="high"][data-theme^="dark"], --app-accordion-border-color: black; --app-card-background-color: #000000; --app-blockquote-border-color: var(--app-muted-border-color); - --app-tooltip-background-color: #fff; + --app-tooltip-bg: #fff; --app-tooltip-color: #0b0f14; --app-action-success-background: #0f5f00; --app-action-success-border: #0f5f00; diff --git a/web/css/components/app-dialog.css b/web/css/components/app-dialog.css index 1d730e6..e15a62b 100644 --- a/web/css/components/app-dialog.css +++ b/web/css/components/app-dialog.css @@ -50,20 +50,28 @@ /* ── Close icon ──────────────────────────────────────────────────────── */ dialog > article .close, dialog > article :is(a, button)[rel="prev"] { - display: block; - width: 1rem; - height: 1rem; - margin-top: calc(var(--app-spacing) * -1); + display: flex; + align-items: center; + justify-content: center; + min-width: 2rem; + min-height: 2rem; + width: 2rem; + height: 2rem; + margin-top: calc(var(--app-spacing) * -0.5); margin-bottom: var(--app-spacing); margin-left: auto; border: none; + border-radius: var(--app-border-radius); background-image: var(--app-icon-close); background-position: center; background-size: auto 1rem; background-repeat: no-repeat; background-color: transparent; opacity: 0.5; - transition: opacity var(--app-transition); + cursor: pointer; + transition: + opacity var(--app-transition), + background var(--app-transition); } dialog > article .close:is([aria-current]:not([aria-current="false"]), :hover, :active, :focus), @@ -71,6 +79,18 @@ opacity: 1; } + dialog > article .close:hover, + dialog > article :is(a, button)[rel="prev"]:hover { + background-color: color-mix(in srgb, var(--app-contrast) 8%, transparent); + } + + dialog > article .close:focus-visible, + dialog > article :is(a, button)[rel="prev"]:focus-visible { + outline: 2px solid var(--app-primary); + outline-offset: -2px; + opacity: 1; + } + /* ── Mobile: stack buttons vertically ────────────────────────────────── */ @media (max-width: 576px) { dialog > article > footer, diff --git a/web/css/components/app-list-tabs.css b/web/css/components/app-list-tabs.css index 5e7e1ba..c182c35 100644 --- a/web/css/components/app-list-tabs.css +++ b/web/css/components/app-list-tabs.css @@ -4,7 +4,7 @@ display: flex; flex-wrap: wrap; margin-bottom: var(--app-spacing); - border-bottom: 1px solid var(--list-tabs-border); + border-bottom: 1px solid var(--app-list-tabs-border); } .app-list-tabs button, @@ -12,7 +12,7 @@ background: transparent; border: 0; padding: 5px 10px 5px 10px; - color: var(--list-tabs-muted); + color: var(--app-list-tabs-muted); cursor: pointer; font-weight: var(--font-medium); border-bottom: 2px solid transparent; @@ -29,6 +29,6 @@ .app-list-tabs button.is-active, .app-list-tabs a.is-active { color: var(--app-color); - border-color: var(--list-tabs-active); + border-color: var(--app-list-tabs-active); } } diff --git a/web/css/components/app-search.css b/web/css/components/app-search.css index 58f8018..79889d6 100644 --- a/web/css/components/app-search.css +++ b/web/css/components/app-search.css @@ -30,10 +30,6 @@ gap: 0.5rem; } - .app-search-dialog-header h2 { - margin: 0; - } - .app-search-dialog-form { display: flex; align-items: center; @@ -66,18 +62,6 @@ border-color: transparent; } - .app-search-dialog-shortcut { - border: 1px solid var(--app-border); - border-radius: 4px; - background: var(--app-card-background-color); - color: var(--app-muted-color); - font-size: var(--text-xs); - line-height: var(--leading-none); - padding: 0.15rem 0.35rem; - pointer-events: none; - flex-shrink: 0; - } - .app-search-dialog-header .close { flex-shrink: 0; } @@ -107,10 +91,6 @@ margin: 0 0 0.25rem; } - .app-search-dialog-empty-state small { - display: block; - } - /* ── Results list ────────────────────────────────────────────────────── */ .app-search-dialog-results { @@ -129,31 +109,20 @@ .app-search-dialog-category-heading { display: flex; align-items: center; - gap: 0.5rem; + gap: 0.75rem; font-size: var(--text-xs); font-weight: var(--font-semibold); color: var(--app-muted-color); text-transform: uppercase; letter-spacing: var(--tracking-wide); - margin-bottom: 5px; + padding: 0.25rem 0.75rem; } - .app-search-dialog-category-heading .bi { - font-size: var(--text-sm); - } - - .app-search-dialog-category-count { - margin-left: auto; - font-size: 10px; /* icon-font metric — intentional px */ - padding: 1px 4px; - min-width: 18px; - text-align: center; - border-radius: 999px; - background: var(--app-accordion-border-color); - display: inline-flex; - justify-content: center; - align-items: center; - height: 18px; + .app-search-dialog-category-heading::after { + content: ''; + flex: 1; + height: 1px; + background: var(--app-border); } /* ── Individual result items ─────────────────────────────────────────── */ @@ -162,8 +131,9 @@ list-style: none; padding: 0; margin: 0; + display: flex; flex-direction: column; - gap: 5px; + gap: 2px; } .app-search-dialog-item { @@ -174,14 +144,45 @@ } .app-search-dialog-item a { - display: block; + display: flex; + align-items: center; + gap: 0.625rem; color: var(--app-color); text-decoration: none; - font-size: var(--text-sm); + font-size: var(--text-base); + padding: 0.5rem 0.75rem; + border-radius: var(--app-border-radius); + } + + .app-search-dialog-item-icon { + color: var(--app-muted-color); + font-size: var(--text-base); + flex-shrink: 0; + } + + .app-search-dialog-item-text { + display: flex; + flex-direction: column; + min-width: 0; + } + + .app-search-dialog-item-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - border-radius: var(--app-border-radius); + } + + .app-search-dialog-item-subtitle { + font-size: var(--text-sm); + color: var(--app-muted-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .app-search-dialog-item mark { + padding: 0; + border-radius: 2px; } .app-search-dialog-item:hover, @@ -193,16 +194,19 @@ color: var(--app-primary); } - /* ── Category "view all" link ────────────────────────────────────────── */ + /* ── "View all" item (last item in category) ─────────────────────────── */ - .app-search-dialog-category-view-all { - font-size: var(--text-xs); - color: var(--app-primary); - text-decoration: none; + .app-search-dialog-item-view-all a { + color: var(--app-muted-color); } - .app-search-dialog-category-view-all:hover { - text-decoration: underline; + .app-search-dialog-item-view-all .app-search-dialog-item-title { + font-size: var(--text-sm); + } + + .app-search-dialog-item-view-all:hover a, + .app-search-dialog-item-view-all.app-search-dialog-item-active a { + color: var(--app-primary); } /* ── Footer ──────────────────────────────────────────────────────────── */ @@ -223,6 +227,14 @@ text-decoration: underline; } + .app-search-dialog-footer-hint kbd { + margin-right: 0.15rem; + } + + .app-search-dialog-footer-hint kbd:not(:first-child) { + margin-left: 0.5rem; + } + /* ── Mobile ──────────────────────────────────────────────────────────── */ @media (max-width: 576px) { diff --git a/web/css/components/app-tabs.css b/web/css/components/app-tabs.css index 6d1c2cb..ec83910 100644 --- a/web/css/components/app-tabs.css +++ b/web/css/components/app-tabs.css @@ -27,7 +27,7 @@ position: relative; display: flex; align-items: stretch; - border-bottom: 1px solid var(--tabs-border); + border-bottom: 1px solid var(--app-tabs-border); } /* Chevron scroll buttons — ghost icons, visible only on overflow. @@ -36,7 +36,7 @@ .app-tabs-nav-wrap > .app-tabs-chevron { --app-background-color: transparent; --app-border-color: transparent; - --app-color: var(--tabs-muted); + --app-color: var(--app-tabs-muted); --app-box-shadow: none; flex: 0 0 auto; display: none; @@ -45,7 +45,7 @@ border: none; border-radius: 50%; background: transparent; - color: var(--tabs-muted); + color: var(--app-tabs-muted); font-size: var(--text-xs); line-height: var(--leading-none); width: 24px; @@ -119,7 +119,7 @@ /* ------------------------------------------------------------------ */ .app-tabs-nav { display: flex; - border-bottom: 1px solid var(--tabs-border); /* No-JS fallback border */ + border-bottom: 1px solid var(--app-tabs-border); /* No-JS fallback border */ overflow-x: auto; -webkit-overflow-scrolling: touch; padding-block-start: 2px; @@ -148,7 +148,7 @@ background: transparent; border: 0; padding: 5px 10px 5px 10px; - color: var(--tabs-muted) !important; + color: var(--app-tabs-muted) !important; cursor: pointer; border-bottom: 2px solid transparent; text-decoration: none; @@ -169,7 +169,7 @@ .app-tabs-nav a.is-active { --app-color: inherit; color: var(--app-color) !important; - border-color: var(--tabs-active); + border-color: var(--app-tabs-active); } .app-tabs-nav button.has-invalid, diff --git a/web/css/components/app-tooltips.css b/web/css/components/app-tooltips.css index 1ce5ecc..9f9cf81 100644 --- a/web/css/components/app-tooltips.css +++ b/web/css/components/app-tooltips.css @@ -17,11 +17,11 @@ [data-tooltip]::after { content: attr(data-tooltip); - background: var(--tooltip-bg); - color: var(--tooltip-color); - border: 1px solid var(--tooltip-border); - border-radius: var(--tooltip-radius); - box-shadow: var(--tooltip-shadow); + background: var(--app-tooltip-bg); + color: var(--app-tooltip-color); + border: 1px solid var(--app-tooltip-border); + border-radius: var(--app-tooltip-radius); + box-shadow: var(--app-tooltip-shadow); font-size: var(--text-xs); padding: 0.35rem 0.6rem; white-space: nowrap; @@ -31,9 +31,9 @@ content: ''; width: 8px; height: 8px; - background: var(--tooltip-bg); - border-left: 1px solid var(--tooltip-border); - border-top: 1px solid var(--tooltip-border); + background: var(--app-tooltip-bg); + border-left: 1px solid var(--app-tooltip-border); + border-top: 1px solid var(--app-tooltip-border); transform: rotate(45deg); } @@ -51,11 +51,11 @@ } [data-tooltip]::after { - transform: translate(var(--tooltip-offset), -50%); + transform: translate(var(--app-tooltip-offset), -50%); } [data-tooltip]::before { - transform: translate(calc(var(--tooltip-offset) - 4px), -50%) rotate(45deg); + transform: translate(calc(var(--app-tooltip-offset) - 4px), -50%) rotate(45deg); } [data-tooltip][data-tooltip-pos="top"]::after, @@ -63,7 +63,7 @@ bottom: 100%; left: 50%; top: auto; - transform: translate(-50%, calc(-1 * var(--tooltip-offset))); + transform: translate(-50%, calc(-1 * var(--app-tooltip-offset))); } [data-tooltip][data-tooltip-pos="top"]::before, @@ -71,33 +71,33 @@ bottom: 100%; left: 50%; top: auto; - transform: translate(-50%, calc(-1 * (var(--tooltip-offset) - 2px))) rotate(45deg); + transform: translate(-50%, calc(-1 * (var(--app-tooltip-offset) - 2px))) rotate(45deg); } [data-tooltip][data-tooltip-pos="bottom"]::after { top: 100%; left: 50%; - transform: translate(-50%, var(--tooltip-offset)); + transform: translate(-50%, var(--app-tooltip-offset)); } [data-tooltip][data-tooltip-pos="bottom"]::before { top: 100%; left: 50%; - transform: translate(-50%, calc(var(--tooltip-offset) - 2px)) rotate(45deg); + transform: translate(-50%, calc(var(--app-tooltip-offset) - 2px)) rotate(45deg); } [data-tooltip][data-tooltip-pos="left"]::after { top: 50%; right: 100%; left: auto; - transform: translate(calc(-1 * var(--tooltip-offset)), -50%); + transform: translate(calc(-1 * var(--app-tooltip-offset)), -50%); } [data-tooltip][data-tooltip-pos="left"]::before { top: 50%; right: 100%; left: auto; - transform: translate(calc(-1 * (var(--tooltip-offset) - 2px)), -50%) rotate(45deg); + transform: translate(calc(-1 * (var(--app-tooltip-offset) - 2px)), -50%) rotate(45deg); } @media (prefers-reduced-motion: reduce) { diff --git a/web/css/layout/app-shell.css b/web/css/layout/app-shell.css index 76c36b5..3300796 100644 --- a/web/css/layout/app-shell.css +++ b/web/css/layout/app-shell.css @@ -88,7 +88,7 @@ } kbd { - --app-font-weight: bolder; + --app-font-weight: var(--font-bold); } :where(select, textarea), diff --git a/web/css/layout/app-sidebar.css b/web/css/layout/app-sidebar.css index 5bfa0d5..4c2119e 100644 --- a/web/css/layout/app-sidebar.css +++ b/web/css/layout/app-sidebar.css @@ -210,11 +210,12 @@ .app-sidebar a { color: inherit; display: block; - padding: 3px var(--app-spacing); + padding: 0.375rem var(--app-spacing); border-radius: 0; margin: 0; border-left: var(--app-sidebar-border-width) solid transparent; width: 100%; + font-size: var(--text-base); white-space: nowrap; text-overflow: ellipsis; overflow: hidden; @@ -286,7 +287,7 @@ overflow: hidden; white-space: nowrap; font-size: var(--text-base); - font-weight: var(--font-normal); + font-weight: var(--font-regular); position: relative; top: 2px; } diff --git a/web/css/layout/app-topbar.css b/web/css/layout/app-topbar.css index fb2956e..6bdd86e 100644 --- a/web/css/layout/app-topbar.css +++ b/web/css/layout/app-topbar.css @@ -26,9 +26,8 @@ --app-topbar-margin-bottom: calc(var(--app-spacing) * 2); --app-topbar-muted: var(--app-form-element-placeholder-color); --app-topbar-bg: var(--app-background-color); - padding-block-start: 0; - padding-left: var(--app-spacing); - padding-right: var(--app-spacing); + padding-block: 0.375rem; + padding-inline: var(--app-spacing); margin-bottom: var(--app-topbar-margin-bottom); color: var(--app-contrast); border-bottom: var(--app-topbar-divider); @@ -71,7 +70,7 @@ display: inline-flex; align-items: center; gap: 0.5rem; - max-width: min(24rem, 50vw); + max-width: min(20rem, 50vw); width: 100%; padding: 0.3rem 0.65rem; border: var(--app-border-width) solid var(--app-border); @@ -93,6 +92,11 @@ border-color: var(--app-primary); } + .app-topbar-search-trigger:focus-visible { + outline: 2px solid var(--app-primary); + outline-offset: -2px; + } + .app-topbar-search-trigger .bi { font-size: var(--text-sm); flex-shrink: 0; @@ -139,35 +143,70 @@ } .app-header details.dropdown > summary:not([role]) { + display: flex; + align-items: center; + justify-content: center; + min-width: 2.25rem; + min-height: 2.25rem; border: none; height: auto; padding: var(--app-topbar-item-padding); - border-radius: 0; + border-radius: var(--app-border-radius); background: transparent; + color: var(--app-topbar-muted); + cursor: pointer; + transition: + background var(--app-transition), + color var(--app-transition); } - /* .app-header details.dropdown > summary:not([role]):after { - display: none; -} */ - .app-topbar-left > li > a, - .app-topbar-right > li > a { - padding: var(--app-topbar-item-padding); - color: var(--app-topbar-muted); - display: block; - margin: 0; - } - + .app-topbar-right > li > a, .app-topbar-left button, .app-topbar-right button { + display: flex; + align-items: center; + justify-content: center; + min-width: 2.25rem; + min-height: 2.25rem; padding: var(--app-topbar-item-padding); - color: var(--app-topbar-muted); - display: block; margin: 0; - background: transparent; border: none; - border-radius: 0; + border-radius: var(--app-border-radius); + background: transparent; + color: var(--app-topbar-muted); + cursor: pointer; + transition: + background var(--app-transition), + color var(--app-transition); } + + .app-topbar-left > li > a:hover, + .app-topbar-right > li > a:hover, + .app-topbar-left button:hover, + .app-topbar-right button:hover, + .app-header details.dropdown > summary:not([role]):hover { + background: color-mix(in srgb, var(--app-contrast) 8%, transparent); + color: var(--app-color); + } + + .app-topbar-left > li > a:active, + .app-topbar-right > li > a:active, + .app-topbar-left button:active, + .app-topbar-right button:active, + .app-header details.dropdown > summary:not([role]):active { + background: color-mix(in srgb, var(--app-contrast) 12%, transparent); + } + + .app-topbar-left > li > a:focus-visible, + .app-topbar-right > li > a:focus-visible, + .app-topbar-left button:focus-visible, + .app-topbar-right button:focus-visible, + .app-header details.dropdown > summary:not([role]):focus-visible { + outline: 2px solid var(--app-primary); + outline-offset: -2px; + } + .app-header #global-back.is-disabled, .app-header #global-forward.is-disabled { opacity: 0.4; @@ -189,6 +228,21 @@ .app-topbar-tenant-menu > summary.app-topbar-tenant-chip { cursor: pointer; + transition: + border-color var(--app-transition), + color var(--app-transition); + } + + .app-topbar-tenant-menu > summary.app-topbar-tenant-chip:hover { + border-color: var(--app-primary); + color: var(--app-color); + } + + .app-topbar-tenant-menu > summary.app-topbar-tenant-chip:focus-visible { + outline: 2px solid var(--app-primary); + outline-offset: -2px; + border-color: var(--app-primary); + color: var(--app-color); } .app-topbar-tenant-name { @@ -201,8 +255,8 @@ white-space: nowrap; } - .app-topbar-user-menu > summary { - color: var(--app-topbar-muted); + .app-topbar-user-menu > summary:hover { + color: var(--app-color); } .app-topbar-menu-list { @@ -257,7 +311,7 @@ padding: 0.375rem 0.5rem; overflow: hidden; border: 0; - border-radius: 3px; + border-radius: var(--app-border-radius); background: transparent; color: var(--app-dropdown-color); font: inherit; @@ -274,12 +328,18 @@ display: block; } + .app-topbar-menu-list li > a:hover, + .app-topbar-menu-list li > a:focus-visible, .app-topbar-menu-list li > button:hover, - .app-topbar-menu-list li > button:focus, .app-topbar-menu-list li > button:focus-visible { background: color-mix(in srgb, var(--app-contrast) 8%, transparent); } + .app-topbar-menu-list li > a.active { + color: var(--app-primary); + font-weight: var(--font-medium); + } + .app-topbar-menu-list li > button.app-topbar-menu-with-hotkey { display: grid; grid-template-columns: 1fr auto; diff --git a/web/css/pages/app-error.css b/web/css/pages/app-error.css index d26f749..97af65d 100644 --- a/web/css/pages/app-error.css +++ b/web/css/pages/app-error.css @@ -12,13 +12,13 @@ font-size: var(--text-5xl); font-weight: var(--font-bold); line-height: var(--leading-none); - color: var(--color-text-muted); + color: var(--app-muted-color); } .error-page__message { margin-top: 1rem; font-size: var(--text-lg); - color: var(--color-text); + color: var(--app-color); } .error-page__back { @@ -27,10 +27,10 @@ padding: 0.5rem 1.25rem; font-size: var(--text-sm); text-decoration: none; - border: 1px solid var(--color-border); - border-radius: 0.375rem; - color: var(--color-text); - background: var(--color-bg-surface); + border: 1px solid var(--app-muted-border-color); + border-radius: var(--app-border-radius); + color: var(--app-color); + background: var(--app-card-background-color); } .error-page__back:hover { diff --git a/web/css/pages/app-login.css b/web/css/pages/app-login.css index 3aa6a85..fa5cf78 100644 --- a/web/css/pages/app-login.css +++ b/web/css/pages/app-login.css @@ -807,7 +807,7 @@ justify-content: flex-start; text-align: left; font-weight: var(--font-bold); - color: var(--app-text); + color: var(--app-color); } .login-tenant-context-text { diff --git a/web/css/vendor-overrides/editorjs.css b/web/css/vendor-overrides/editorjs.css index b3ce094..5648ea2 100644 --- a/web/css/vendor-overrides/editorjs.css +++ b/web/css/vendor-overrides/editorjs.css @@ -54,7 +54,7 @@ table.tc-table { } .tc-cell { - border-left: 1px solid var(--color-border); + border-left: 1px solid var(--app-muted-border-color); } .ce-toolbar__actions { diff --git a/web/js/components/app-global-search.js b/web/js/components/app-global-search.js index 2b6f2fb..7c19693 100644 --- a/web/js/components/app-global-search.js +++ b/web/js/components/app-global-search.js @@ -42,8 +42,6 @@ export function initGlobalSearch(root = document, options = {}) { const footerEl = dialog.querySelector('[data-search-dialog-footer]'); const viewAllLink = dialog.querySelector('[data-search-dialog-view-all]'); const closeButton = dialog.querySelector('[data-app-search-dialog-close]'); - const shortcutEl = dialog.querySelector('[data-search-shortcut]'); - const emptyHintEl = dialog.querySelector('[data-search-empty-hint-template]'); const triggerButton = document.querySelector('[data-app-search-trigger]'); const triggerShortcutEl = document.querySelector('[data-search-trigger-shortcut]'); @@ -57,17 +55,13 @@ export function initGlobalSearch(root = document, options = {}) { let pending = null; let activeIndex = -1; let flatItems = []; + let abortController = null; const shortcutLabel = /(Mac|iPhone|iPad|iPod)/i.test(navigator.platform || navigator.userAgent) ? '⌘K' : 'Ctrl+K'; - if (shortcutEl) {shortcutEl.textContent = shortcutLabel;} if (triggerShortcutEl) {triggerShortcutEl.textContent = shortcutLabel;} - if (emptyHintEl) { - const template = emptyHintEl.getAttribute('data-search-empty-hint-template') || ''; - emptyHintEl.textContent = template.replace('{shortcut}', shortcutLabel); - } // --- State management --- @@ -119,14 +113,14 @@ export function initGlobalSearch(root = document, options = {}) { const heading = document.createElement('div'); heading.classList.add('app-search-dialog-category-heading'); - heading.innerHTML = ` ${escapeHtml(category.label || category.key)} ${category.count}`; + heading.textContent = category.label || category.key; group.appendChild(heading); - if (Array.isArray(category.items) && category.items.length > 0) { - const list = document.createElement('ul'); - list.classList.add('app-search-dialog-category-items'); - list.setAttribute('role', 'group'); + const list = document.createElement('ul'); + list.classList.add('app-search-dialog-category-items'); + list.setAttribute('role', 'group'); + if (Array.isArray(category.items) && category.items.length > 0) { category.items.forEach((item) => { if (!item || !item.url) {return;} const li = document.createElement('li'); @@ -137,26 +131,62 @@ export function initGlobalSearch(root = document, options = {}) { const link = document.createElement('a'); link.href = item.url; - link.textContent = item.label || ''; link.tabIndex = -1; + + const itemIcon = item.icon || iconForKey(category.key); + const icon = document.createElement('i'); + icon.className = `bi ${safeCssClass(itemIcon)} app-search-dialog-item-icon`; + icon.setAttribute('aria-hidden', 'true'); + link.appendChild(icon); + + const textWrap = document.createElement('span'); + textWrap.classList.add('app-search-dialog-item-text'); + const titleEl = document.createElement('span'); + titleEl.classList.add('app-search-dialog-item-title'); + highlightText(titleEl, item.label || '', query); + textWrap.appendChild(titleEl); + if (item.subtitle) { + const subtitleEl = document.createElement('span'); + subtitleEl.classList.add('app-search-dialog-item-subtitle'); + highlightText(subtitleEl, item.subtitle, query); + textWrap.appendChild(subtitleEl); + } + link.appendChild(textWrap); + li.appendChild(link); list.appendChild(li); flatItems.push(li); itemIndex++; }); - - group.appendChild(list); } - if (category.url && category.count > 0) { - const viewCategoryLink = document.createElement('a'); - viewCategoryLink.classList.add('app-search-dialog-category-view-all'); - viewCategoryLink.href = category.url; - viewCategoryLink.innerHTML = `${escapeHtml(category.label || category.key)} `; - group.appendChild(viewCategoryLink); + if (category.url && category.count > (category.items?.length || 0)) { + const viewLi = document.createElement('li'); + viewLi.classList.add('app-search-dialog-item', 'app-search-dialog-item-view-all'); + const viewLink = document.createElement('a'); + viewLink.href = category.url; + viewLink.tabIndex = -1; + + const viewIcon = document.createElement('i'); + viewIcon.className = 'bi bi-arrow-right app-search-dialog-item-icon'; + viewIcon.setAttribute('aria-hidden', 'true'); + viewLink.appendChild(viewIcon); + + const viewText = document.createElement('span'); + viewText.classList.add('app-search-dialog-item-text'); + const viewTitle = document.createElement('span'); + viewTitle.classList.add('app-search-dialog-item-title'); + viewTitle.textContent = `${category.label || category.key} — ${category.count}`; + viewText.appendChild(viewTitle); + viewLink.appendChild(viewText); + + viewLi.appendChild(viewLink); + list.appendChild(viewLi); } + group.appendChild(list); + resultsEl.appendChild(group); }); @@ -182,12 +212,16 @@ export function initGlobalSearch(root = document, options = {}) { // --- Fetch --- const fetchResults = async (value) => { + if (abortController) {abortController.abort();} + abortController = new AbortController(); + const { signal } = abortController; + setState('loading'); const url = new URL(endpoint.toString()); url.searchParams.set('q', value); try { - const response = await fetch(url.toString(), { headers: { 'Accept': 'application/json' } }); + const response = await fetch(url.toString(), { headers: { 'Accept': 'application/json' }, signal }); if (!response.ok) { warnOnce('FETCH_FAILED', `Global search failed: ${response.status} ${response.statusText}`, { module: 'global-search' }); setState('error'); @@ -201,6 +235,7 @@ export function initGlobalSearch(root = document, options = {}) { setState('error'); } } catch (err) { + if (err.name === 'AbortError') {return;} warnOnce('FETCH_ERROR', 'Global search fetch error', { module: 'global-search', err }); setState('error'); } @@ -212,14 +247,6 @@ export function initGlobalSearch(root = document, options = {}) { if (dialog.open) {return;} dialog.showModal(); setState('empty'); - if (emptyStateEl) { - const p = emptyStateEl.querySelector('p'); - if (p) { - p.textContent = emptyHintEl - ? '' - : (dialog.dataset.emptyText || ''); - } - } resetEmptyState(); window.requestAnimationFrame(() => { if (input) { @@ -280,6 +307,8 @@ export function initGlobalSearch(root = document, options = {}) { const onInput = () => { const value = input.value.trim(); if (value.length < minLength) { + if (pending) {window.clearTimeout(pending); pending = null;} + if (abortController) {abortController.abort(); abortController = null;} setState('empty'); resetEmptyState(); resultsEl.innerHTML = ''; @@ -386,6 +415,7 @@ export function initGlobalSearch(root = document, options = {}) { const destroy = () => { if (pending) {window.clearTimeout(pending);} + if (abortController) {abortController.abort();} if (input) { input.removeEventListener('input', onInput); input.removeEventListener('keydown', onInputKeydown); @@ -410,3 +440,30 @@ function escapeHtml(text) { div.appendChild(document.createTextNode(text)); return div.innerHTML; } + +function safeCssClass(value) { + return String(value).replace(/[^a-z0-9_-]/gi, ''); +} + +function highlightText(el, text, query) { + if (!query || query.length < 2) { + el.textContent = text; + return; + } + const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const parts = text.split(new RegExp(`(${escaped})`, 'gi')); + if (parts.length === 1) { + el.textContent = text; + return; + } + const queryLower = query.toLowerCase(); + parts.forEach((part) => { + if (part.toLowerCase() === queryLower) { + const mark = document.createElement('mark'); + mark.textContent = part; + el.appendChild(mark); + } else if (part !== '') { + el.appendChild(document.createTextNode(part)); + } + }); +}