From 21c2b55bfbda5ae5192594d97047d95d52cd8423 Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Fri, 8 May 2026 19:24:24 +0200 Subject: [PATCH] style: normalize headers, overtype editor rounded corners, remove duplicate cover preview, thesis-add-header grid layout, subtitle below header with top gradient --- TODO.md | 137 +-- app/.env | 1 + .../022_form_help_blocks_name_enabled.sql | 16 + .../applied/023_add_missing_help_blocks.sql | 8 + .../admin/actions/form-help-reorder.php | 40 +- app/public/admin/add.php | 2 +- app/public/admin/contenus-edit.php | 2 +- app/public/admin/edit.php | 2 +- .../admin/form-help-inline-fragment.php | 196 +++ app/public/admin/licence-fragment.php | 46 + app/public/assets/css/admin.css | 402 ++++--- app/public/assets/css/common.css | 821 ++++++------- app/public/assets/css/form.css | 1072 +++++++++-------- .../assets/js/overtype-webcomponent.min.js | 126 ++ app/public/partage/fichiers-fragment.php | 24 +- app/public/partage/index.php | 10 +- app/public/partage/licence-fragment.php | 47 + app/src/Database.php | 75 +- app/templates/admin/contenus.php | 210 ++-- app/templates/header.php | 24 +- .../form/fieldset-licence-explanation.php | 61 +- .../partials/form/form-help-block.php | 3 +- app/templates/partials/form/form.php | 61 +- 23 files changed, 1855 insertions(+), 1531 deletions(-) create mode 100644 app/.env create mode 100644 app/migrations/applied/022_form_help_blocks_name_enabled.sql create mode 100644 app/migrations/applied/023_add_missing_help_blocks.sql create mode 100644 app/public/admin/form-help-inline-fragment.php create mode 100644 app/public/admin/licence-fragment.php create mode 100644 app/public/assets/js/overtype-webcomponent.min.js create mode 100644 app/public/partage/licence-fragment.php diff --git a/TODO.md b/TODO.md index dd234fe..2b0753e 100644 --- a/TODO.md +++ b/TODO.md @@ -1,129 +1,12 @@ -# XAMXAM TODO - -## Completed - -- [x] PeerTube integration — two parallel systems (backup direct upload + PeerTube API) - - [x] `PeerTubeService.php` — credentials CRUD + OAuth2 password grant + multipart upload to `/api/v1/videos/upload` - - [x] Migration `021_peertube_settings.sql` — `peertube_settings` table (singleton) + `peertube_upload_enabled` feature flag (default 0 = disabled) - - [x] `actions/settings.php` — `peertube` section handler (toggle + credential save) - - [x] `admin/parametres.php` — PeerTube section UI (instance URL, username, password, channel ID, privacy) - - [x] `templates/admin/parametres.php` — PeerTube settings form between SMTP and admin account sections - - [x] `admin/partage/fichiers-fragment.php` — shows `` for video/audio when enabled, keeps TODO notice when disabled - - [x] `ThesisCreateController` — `handlePeerTubeUpload()` uploads video/audio to PeerTube, stores watch URL as `thesis_files` row - - [x] `ThesisEditController` — same `handlePeerTubeUpload()` method for edit workflow - - [x] `templates/public/tfe.php` — renders PeerTube iframe embed for files whose path contains `/videos/watch/` - - [x] `AdminLogger` — `logPeerTubeUpdate()` audit method - - [x] Direct file upload fallback: when `peertube_upload_enabled = 0`, standard `` + local storage works unchanged - -- [x] Backoffice fieldset reorder — Note contextuelle merged in, Lien BAIU added, removed from Métadonnées - - [x] Backoffice order: Note contextuelle → Points du jury → Remarques → Lien BAIU → Exemplaire BAIU → Exemplaire ERG → Contact interne - - [x] Removed standalone "Note contextuelle" fieldset (now inside Backoffice) - - [x] Lien BAIU moved from Métadonnées complémentaires into Backoffice - - [x] Métadonnées fieldset now: pages, minutes, annexes only - -- [x] Form fixes batch - - [x] bentopdf link clearer: "PDFs trop lourds ? https://bentopdf.com/" (full URL visible) - - [x] Multiple promoteurices: interne and ULB fields now dynamic (add/remove rows, same as lecteurs) - - [x] Contact visibility duplication removed from admin forms (`showContact = false`; `mail` field in fieldset-tfe-info covers it) - - [x] Asterisk corrections in files section: note_intention, website URL, video, audio all show red asterisk + `required` when non-admin - - [x] ULB promoteurice asterisk + required when finality=Approfondi (JS toggles `*` + `required` on first ULB input) - - [x] Controllers handle `jury_promoteur` and `jury_promoteur_ulb_name` as both scalar and array (backwards compat) - -- [x] Fix `just serve` — justfile shebang recipes (`deploy-env`, `reencrypt-password`) used space indentation instead of tabs, causing "extra leading whitespace" parse error - -- [x] PDF 100 MB limit + bentopdf mention - - [x] `ThesisCreateController`: `MAX_PDF_SIZE = 100 MB`; PDFs checked against it, other files still 500 MB - - [x] `ThesisEditController`: same per-PDF limit applied - - [x] `fichiers-fragment.php`: note d'intention and TFE hints mention 100 MB PDF limit + bentopdf.com link - - [x] `form.php` edit-mode new-files hint updated - - [x] `file-field.php`: added `$hintRaw` flag to allow HTML in hints - -- [x] Format types: reorder, rename, add Image/Écriture - - [x] Migration 019: add Écriture - - [x] Migration 020: add `sort_order` column, rename Autre → Etc. / Autre, add Image, set display order (Écriture · Image · Audio · Vidéo · Site web · Performance · Objet éditorial · Installation · Etc. / Autre) - - [x] `Database.php` format_types query uses `ORDER BY sort_order, id` - - [x] `fichiers-fragment.php` uses `ORDER BY sort_order, id`; Image/Vidéo/Audio IDs resolved via name map - - [x] TODO: Vidéo + Audio — PeerTube API upload (notice shown in form for now) - -- [x] Combined Format + Fichiers into HTMX-swappable block - - [x] `partage/fichiers-fragment.php` — new combined fragment: format checkboxes + fichiers fieldset that adapts based on selected formats (upload inputs / URL fields / both) - - [x] Route `/partage/fichiers-fragment` added to `partage/index.php` - - [x] `admin/fichiers-fragment.php` — admin-gated wrapper for the same fragment (sets `admin_mode=1`) - - [x] `admin/format-website-fragment.php` — admin-gated fragment for edit-mode website URL fieldset toggle - - [x] `form.php` — add/partage mode: replaced separate Format + Fichiers + website-url-fieldset with single `#format-fichiers-block` server-rendered via shared fragment - - [x] `form.php` — edit mode: Format checkboxes wire to `admin/format-website-fragment.php` → `#edit-website-url-fieldset` (existing-file management untouched) - - [x] `checkbox-list.php` — added `$hxInclude` variable (defaults to `'this, #website-url-fieldset'`) so callers can customise included fields - -- [x] TDD analysis + new test suites - - [x] **Bug fixed**: `SearchController::handleSearch()` — `$coverMap` undefined variable + never populated for search results - - [x] `ShareLinkTest` (13 tests) — `generateSlug`, all `validateLink` branches, `verifyPassword`, `incrementUsage`, `objet_restriction` - - [x] `PureLogicTest` (31 tests) — `TfeController` helpers (meta, OG image, jury split, captions), `ThesisCreateController` helpers (autofocus, detectFileType, authorSlug), `ThesisEditController::buildFileSizeInfo`, `ExportController` CSV column consistency, `SearchController` coverMap regression - - [x] Private helpers promoted to `protected` in `TfeController`, `ThesisCreateController`, `ThesisEditController` to enable subclass-based testing without reflection - -- [x] Form save audit + TDD - - [x] `createThesis()` missing `duration_pages`/`duration_minutes` columns — fixed - - [x] `ThesisCreateController` not passing raw page/minute values to `createThesis()` — fixed (`durationPages`, `durationMinutes` extracted and passed) - - [x] `FormSaveTest.php` — 14 red-green tests covering create+edit round-trips for all fields - -- [x] Language form improvements - - [x] Add Néerlandais as default language option (schema + migration 017) - - [x] `language_autre` conditionally required via HTMX fragment (replaced custom JS) - - [x] `language_autre` saved via `getOrCreateLanguage()` in both create and edit controllers - - [x] `formData['languages']` wired in edit.php so checkboxes are pre-checked - - [x] `duration_pages`/`duration_minutes` saved in `updateThesis()` and read back in `getThesisRawFields()` - - [x] `beforeunload-guard` applied to add and partage forms too - -- [x] Audit + fix direct PHP URL references blocked by nginx catch-all `deny all` - - [x] `/request-access.php` fetch in `tfe.php` → `/request-access` - - [x] `/media.php?path=` in `form.php` (×2) and `admin/recapitulatif.php` → `/media?path=` - -- [x] Fix 403 on `/language-autre-fragment.php` from `edit.php` - - [x] Root cause: standalone root-level PHP file blocked by nginx catch-all `deny all` - - [x] Moved logic to `partage/language-autre-fragment.php` (shared include) - - [x] Added route `/partage/language-autre-fragment` in `partage/index.php` - - [x] Added `admin/language-autre-fragment.php` (AdminAuth gated, includes shared logic) - - [x] `form.php` picks URL based on `$mode` (`partage` vs admin) - - [x] Deleted `public/language-autre-fragment.php`; nginx unchanged - -- [x] Merge banner images into cover images - - [x] Migration 016: copy `storage/banners/*` → `storage/covers/`, insert `thesis_files` cover records, clear `banner_path`, remove banners dir - - [x] Remove banner fieldset from edit form (`form.php`) - - [x] Remove banner fieldset from student submission form (`fieldset-files.php`: rename to couverture) - - [x] Update `ThesisEditController::save()` — remove banner upload/removal logic - - [x] Update `ThesisCreateController::submit()` — remove `handleBannerUpload` call - - [x] Update `Database::handleCoverUpload()` — add webp support, raise limit to 20 MB - - [x] Remove `Database::setBannerPath()`, `handleBannerUpload()`, `getThesisBannerPath()` - - [x] Update `Database::deleteThesis()` / `bulkDeleteTheses()` — remove banner file cleanup - - [x] `HomeController`: batch-load covers for all items, remove banner_path fallback - - [x] `SearchController::handleSearch()`: batch-load covers, pass `$coverMap` to view - - [x] `SearchController::handleStudentPreview()`: load covers, pass `$coverMap` to partial - - [x] `TfeController::resolveOgImage()`: use cover file_type instead of banner_path - - [x] `home.php`: use only `$coverMap` (no banner_path fallback) - - [x] `search.php`: show cover thumbnail on result cards - - [x] `student-preview.php`: use `$coverMap` instead of `banner_path` - - [x] Migration applied and file moved to `applied/` - -- [x] Remove `required` from all form inputs in admin add/edit - - [x] Introduced `$adminMode` flag in `form.php` (true when `$mode` is `'add'` or `'edit'`) - - [x] Hidden "champs obligatoires" note in admin mode - - [x] All `$required = true` callers in `form.php`, `fieldset-tfe-info.php`, `fieldset-academic.php`, `fieldset-licence-explanation.php`, `fieldset-files.php` changed to `!$adminMode` - - [x] Hardcoded `required` HTML attributes in `fieldset-tfe-info.php` (synopsis, objet radios), `fieldset-licence-explanation.php` (access type radios), `jury-fieldset.php` (promoteur, lecteurs interne/externe) gated on `!$adminMode` - - [x] Dynamic JS `ulbInput.required` in jury fieldset also gated # TODO -- [x] Make all heading font sizes the same (slightly smaller than current h1) in common.css -- [x] Remove individual font-size overrides from other CSS files so they inherit -- [x] Standardise header nav structure: admin uses nav-left/nav-right like public -- [x] Unify font-size for all nav links (logo + nav links all use var(--step--1)) -- [x] Clean up redundant CSS rules (.nav-logo, .nav-left-links) -- [x] Update admin.css selectors to match new header structure -- [x] Bump nav font-size to var(--step-0) -- [x] Add small inverted top gradient to admin body -- [x] Commit -- [x] Cap home page cards grid to max 3 columns (was auto-fill, now repeat(3, 1fr) with 2→1 column breakpoints) -- [x] Remove Modifier link from admin header when on edit page -- [x] Move admin nav links to right side, keep only logo on left -- [x] Remove Mots-clés from admin header, add as button in dashboard toolbar; use grid layout (title|stats, search|buttons) -- [x] Group admin toolbar buttons: + Ajouter + Mots-clés stacked above Import/Export -- [x] Stack admin filters vertically: search+button row above dropdowns row -- [x] Standardise form inputs/selects/textareas in common.css: padding, --radius var, 2px accent border on focus +- [x] Add fixed top gradient on partage main element, mirror of bottom gradient +- [x] Remove bottom border on `.thesis-add-header` +- [x] Add subtitle "Formulaire pour XAMXAM" below partage header, with Ductus link to / +- [x] Switch `.thesis-add-header` to grid layout +- [ ] Create `admin/operation.php` — unified add/edit page +- [ ] Wire up route: `?id=` → edit mode, no id → add mode +- [ ] Update all references: `add.php` → `operation.php`, `edit.php` → `operation.php?id=` +- [ ] Keep old `add.php` and `edit.php` as redirect stubs +- [ ] Keep action endpoints (`actions/formulaire.php`, `actions/edit.php`) unchanged +- [ ] Test both flows diff --git a/app/.env b/app/.env new file mode 100644 index 0000000..eaca243 --- /dev/null +++ b/app/.env @@ -0,0 +1 @@ +APP_KEY=M+4xc/c9/b4H+ScjO4cU82FzcZRO+xp5pzYNvJapUOQ= diff --git a/app/migrations/applied/022_form_help_blocks_name_enabled.sql b/app/migrations/applied/022_form_help_blocks_name_enabled.sql new file mode 100644 index 0000000..192cba3 --- /dev/null +++ b/app/migrations/applied/022_form_help_blocks_name_enabled.sql @@ -0,0 +1,16 @@ +-- Add name (human-readable title) and enabled (show/hide toggle) columns +-- to form_help_blocks. Drop sort_order — blocks are now fixed-position, +-- one per fieldset, no reordering. + +ALTER TABLE form_help_blocks ADD COLUMN name TEXT NOT NULL DEFAULT ''; +ALTER TABLE form_help_blocks ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1; + +-- Backfill names from the FORM_HELP_LABELS mapping. +UPDATE form_help_blocks SET name = 'Introduction' WHERE key = 'partage_intro'; +UPDATE form_help_blocks SET name = 'Informations du TFE' WHERE key = 'fieldset_tfe_info'; +UPDATE form_help_blocks SET name = 'Note Synopsis' WHERE key = 'fieldset_synopsis'; +UPDATE form_help_blocks SET name = 'Composition du jury' WHERE key = 'fieldset_jury'; +UPDATE form_help_blocks SET name = 'Cadre académique' WHERE key = 'fieldset_academic'; +UPDATE form_help_blocks SET name = 'Fichiers' WHERE key = 'fieldset_files'; +UPDATE form_help_blocks SET name = 'Visibilité / Accès' WHERE key = 'fieldset_access'; +UPDATE form_help_blocks SET name = 'E-mail de confirmation' WHERE key = 'fieldset_email'; diff --git a/app/migrations/applied/023_add_missing_help_blocks.sql b/app/migrations/applied/023_add_missing_help_blocks.sql new file mode 100644 index 0000000..9646aad --- /dev/null +++ b/app/migrations/applied/023_add_missing_help_blocks.sql @@ -0,0 +1,8 @@ +-- Add missing form help block keys for all student-form fieldsets. +-- fieldset_synopsis is already seeded but injected inside Informations du TFE via $synopsisExtra. +-- fieldset_academic already exists but was never wired in form.php for partage mode. + +INSERT OR IGNORE INTO form_help_blocks (key, name, content, enabled) VALUES + ('fieldset_languages', 'Langue(s)', '', 1), + ('fieldset_keywords', 'Mots-clés', '', 1), + ('fieldset_metadata', 'Métadonnées complémentaires', '', 1); diff --git a/app/public/admin/actions/form-help-reorder.php b/app/public/admin/actions/form-help-reorder.php index 9c2418a..7fa5d40 100644 --- a/app/public/admin/actions/form-help-reorder.php +++ b/app/public/admin/actions/form-help-reorder.php @@ -1,47 +1,11 @@ reorderFormHelpBlocks($keys); -} catch (Exception $e) { - error_log('form-help-reorder error: ' . $e->getMessage()); - http_response_code(500); - echo 'Erreur lors de la sauvegarde.'; - exit; -} - http_response_code(204); exit; diff --git a/app/public/admin/add.php b/app/public/admin/add.php index bba0e84..84c1375 100644 --- a/app/public/admin/add.php +++ b/app/public/admin/add.php @@ -27,7 +27,7 @@ $autofocusField = App::consumeAutofocus(); $siteSettings = Database::getInstance()->getAllSettings(); // Form help blocks $helpBlocks = Database::getInstance()->getAllFormHelpBlocks(); -$helpFn = fn(string $key) => $helpBlocks[$key]['content'] ?? ''; +$helpFn = fn(string $key) => empty($helpBlocks[$key]['enabled']) ? '' : ($helpBlocks[$key]['content'] ?? ''); function withAutofocus(string $fieldName, array $attrs = []): array { global $autofocusField; diff --git a/app/public/admin/contenus-edit.php b/app/public/admin/contenus-edit.php index 2ef536f..c59635c 100644 --- a/app/public/admin/contenus-edit.php +++ b/app/public/admin/contenus-edit.php @@ -50,7 +50,7 @@ try { } elseif ($formHelpKey) { $editType = "form_help"; $formHelpContent = $db->getFormHelpBlock($formHelpKey); - $editTitle = Database::FORM_HELP_LABELS[$formHelpKey] ?? $formHelpKey; + $editTitle = $formHelpKey; } else { $editType = "apropos"; $value = $db->getAproposContent($aproposKey); diff --git a/app/public/admin/edit.php b/app/public/admin/edit.php index 6a695a5..7b0202e 100644 --- a/app/public/admin/edit.php +++ b/app/public/admin/edit.php @@ -19,7 +19,7 @@ $autofocusField = App::consumeAutofocus(); // Form help blocks for editable généralités $helpBlocks = Database::getInstance()->getAllFormHelpBlocks(); -$helpFn = fn(string $key) => $helpBlocks[$key]['content'] ?? ''; +$helpFn = fn(string $key) => empty($helpBlocks[$key]['enabled']) ? '' : ($helpBlocks[$key]['content'] ?? ''); function old($key, $default = "") { global $formData; diff --git a/app/public/admin/form-help-inline-fragment.php b/app/public/admin/form-help-inline-fragment.php new file mode 100644 index 0000000..885d051 --- /dev/null +++ b/app/public/admin/form-help-inline-fragment.php @@ -0,0 +1,196 @@ +toggleFormHelpBlock($key); + renderCollapsed($db, $key); + exit; + } + + // Save requires CSRF + if (!isset($_POST['csrf_token'], $_SESSION['csrf_token']) + || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) { + http_response_code(403); + echo 'Token invalide.'; + exit; + } + + // save + $content = $_POST['content'] ?? ''; + $name = trim($_POST['name'] ?? ''); + try { + $db->setFormHelpBlock($key, $content); + if ($name !== '') { + $db->setFormHelpBlockName($key, $name); + } + require_once __DIR__ . '/../../src/AdminLogger.php'; + AdminLogger::make()->logFormStructureEdit($key); + } catch (Exception $e) { + error_log('form-help-inline save error: ' . $e->getMessage()); + http_response_code(500); + echo 'Erreur lors de la sauvegarde.'; + exit; + } + + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + renderCollapsed($db, $key); + exit; +} + +// ── GET ────────────────────────────────────────────────────────────────────── +$db = new Database(); +$editMode = ($_GET['edit'] ?? '') === '1'; + +if ($editMode) { + renderEditor($db, $key); +} else { + renderCollapsed($db, $key); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// RENDER HELPERS +// ═══════════════════════════════════════════════════════════════════════════════ + +function renderCollapsed(Database $db, string $key): void +{ + $blocks = $db->getAllFormHelpBlocks(); + $b = $blocks[$key] ?? ['content' => '', 'name' => '', 'enabled' => 0]; + $name = $b['name'] ?: $key; + $content = $b['content'] ?? ''; + $enabled = (int)($b['enabled'] ?? 1); + $hasContent = trim($content) !== ''; + + $mdHtml = ''; + if ($hasContent) { + require_once APP_ROOT . '/src/Parsedown.php'; + $pd = new Parsedown(); + $pd->setSafeMode(true); + $mdHtml = $pd->text($content); + } + ?> +
+ + +
+
+ +
+ +
— vide —
+ +
+ + +
+
+ + +
+ + + + +
+ +
+ getAllFormHelpBlocks(); + $b = $blocks[$key] ?? ['content' => '', 'name' => '']; + $name = $b['name'] ?: $key; + $content = $b['content'] ?? ''; + ?> +
+
+ + +
+ + +
+ + + +
+ +
+ + +
+
+ +
+ getAllLicenseTypes(); +?> + +
+ + + + +
+ + En savoir plus sur la CC2r ↗ +
+
diff --git a/app/public/assets/css/admin.css b/app/public/assets/css/admin.css index 2ab6004..d7f4edc 100644 --- a/app/public/assets/css/admin.css +++ b/app/public/assets/css/admin.css @@ -16,7 +16,6 @@ white-space: nowrap; scrollbar-width: thin; } -.admin-body header nav .nav-left, .admin-body header nav .nav-right-links { flex-shrink: 0; } @@ -1480,7 +1479,7 @@ } /* ═══════════════════════════════════════════════════════════════════════════ - Form Help Blocks — drag-and-drop builder (contenus.php) + Form Help Blocks — static structure view (contenus.php) ═══════════════════════════════════════════════════════════════════════════ */ .fhb-hint { @@ -1489,225 +1488,250 @@ margin-bottom: var(--space-m); } -.fhb-layout { +/* ── Structure container ───────────────────────────────────────────────────── */ + +.fhb-structure { display: grid; - grid-template-columns: 1fr 1fr; - gap: var(--space-m); - align-items: start; + grid-template-columns: 1fr; + gap: var(--space-xs); + max-width: 100%; margin-top: var(--space-m); } -@media (max-width: 800px) { - .fhb-layout { - grid-template-columns: 1fr; - } -} +/* ── Fieldset cards (static reference) ─────────────────────────────────────── */ -/* ── Panels ─────────────────────────────────────────────────────────────── */ - -.fhb-sortable-panel, -.fhb-form-preview-panel { +.fhb-fieldset-card { border: 1px solid var(--border-primary); border-radius: var(--radius); - padding: var(--space-s); + padding: var(--space-xs) var(--space-s); background: var(--bg-secondary); } -.fhb-panel-title { +.fhb-fieldset-card-legend { font-size: var(--step-0); font-weight: 600; - margin: 0 0 var(--space-3xs) 0; - letter-spacing: 0.03em; -} - -.fhb-panel-desc { - font-size: var(--step--2); - color: var(--text-secondary); - margin: 0 0 var(--space-xs) 0; -} - -/* ── Saving indicator ─────────────────────────────────────────────────────── */ - -.fhb-saving { - display: none; - align-items: center; - gap: var(--space-2xs); - font-size: var(--step--1); - color: var(--accent-primary); - padding: var(--space-2xs) 0; -} - -.fhb-saving.htmx-request { - display: flex; -} - -/* ── Draggable block cards ─────────────────────────────────────────────────── */ - -.fhb-sortable { - display: flex; - flex-direction: column; - gap: var(--space-2xs); - padding: 0; - margin: 0; -} - -.fhb-block-card { - display: flex; - align-items: center; - gap: var(--space-xs); - background: var(--bg-primary); - border: 1px solid var(--border-primary); - border-left: 4px solid var(--accent-primary); - border-radius: var(--radius); - padding: var(--space-2xs) var(--space-xs); - cursor: default; - transition: box-shadow 0.15s, border-color 0.15s; -} - -.fhb-block-card:hover { - box-shadow: 0 2px 8px rgba(0,0,0,0.08); - border-color: var(--accent-primary); -} - -.fhb-drag-handle { - font-size: 1.2em; - color: var(--text-tertiary); - cursor: grab; - flex-shrink: 0; - line-height: 1; - user-select: none; - padding: 2px 4px; -} - -.fhb-drag-handle:active { - cursor: grabbing; -} - -.fhb-block-info { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; -} - -.fhb-block-label { - font-size: var(--step--1); - font-weight: 500; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.fhb-block-preview { - font-size: var(--step--2); - color: var(--text-secondary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.fhb-block-empty { - font-size: var(--step--2); - color: var(--text-tertiary); - font-style: italic; -} - -.fhb-edit-btn { - flex-shrink: 0; - font-size: var(--step--2) !important; - padding: 2px var(--space-xs) !important; -} - -/* ── SortableJS state classes ─────────────────────────────────────────────── */ - -.fhb-ghost { - opacity: 0.35; - background: var(--accent-muted); - border-color: var(--accent-primary); -} - -.fhb-chosen { - box-shadow: 0 4px 16px rgba(149, 87, 181, 0.25); - border-color: var(--accent-primary); -} - -.fhb-dragging { - opacity: 0.9; - box-shadow: 0 8px 24px rgba(0,0,0,0.15); -} - -/* ── Form structure preview (right panel) ─────────────────────────────────── */ - -.fhb-form-preview { - display: flex; - flex-direction: column; - gap: var(--space-2xs); -} - -.fhb-fieldset-preview { - border: 1px solid var(--border-secondary); - border-radius: var(--radius); - padding: var(--space-xs); - background: var(--bg-primary); -} - -.fhb-fieldset-legend { - font-size: var(--step--1); - font-weight: 600; color: var(--text-primary); margin-bottom: var(--space-3xs); padding-bottom: var(--space-3xs); border-bottom: 1px solid var(--border-primary); } -.fhb-fieldset-inputs { +.fhb-fieldset-card-inputs { margin: 0; padding: 0 0 0 var(--space-s); - list-style: disc; + list-style: none; + display: flex; + flex-wrap: wrap; + gap: var(--space-3xs) var(--space-m); } -.fhb-fieldset-inputs li { +.fhb-fieldset-card-inputs li { font-size: var(--step--2); color: var(--text-secondary); line-height: 1.6; } -.fhb-anchor { - display: flex; - align-items: center; - gap: var(--space-2xs); - border-radius: var(--radius); - padding: var(--space-3xs) var(--space-xs); - font-size: var(--step--2); - border: 1px dashed var(--border-primary); - background: transparent; -} - -.fhb-anchor--filled { - border-color: var(--accent-primary); - background: var(--accent-muted); - color: var(--accent-secondary); -} - -.fhb-anchor--empty { +.fhb-fieldset-card-inputs li::before { + content: '· '; color: var(--text-tertiary); } -.fhb-anchor-icon { - flex-shrink: 0; - font-style: normal; +/* ── Help block wrapper ────────────────────────────────────────────────────── */ + +.fhb-block-wrapper { + /* container for the fhb-inline, one per help block */ } -.fhb-anchor-label { - flex: 1; -} +/* ── Inline help block (collapsed state) ───────────────────────────────────── */ -.fhb-anchor-pos { - font-size: var(--step--2); - font-weight: 600; - color: var(--accent-primary); - background: var(--accent-muted); +.fhb-inline { + display: grid; + grid-template-columns: 1fr auto; + gap: var(--space-s); + align-items: center; + border: 1px solid var(--border-primary); + border-left: 4px solid var(--accent-primary); border-radius: var(--radius); - padding: 0 4px; + padding: var(--space-xs) var(--space-s); + background: var(--bg-primary); + transition: opacity 0.15s; +} + +.fhb-inline:hover { + opacity: 0.85; +} + +/* Disabled state — one line only, content hidden */ +.fhb-inline--disabled { + border-left-color: var(--text-tertiary); + opacity: 0.55; +} + +.fhb-inline--disabled:hover { + opacity: 0.75; +} + +.fhb-inline--disabled .fhb-md-preview, +.fhb-inline--disabled .fhb-inline-empty { + display: none; +} + +/* Editing state */ +.fhb-inline--editing { + display: grid; + grid-template-columns: 1fr; + grid-template-rows: auto 1fr auto; + border-color: var(--accent-primary); + box-shadow: 0 4px 16px rgba(149, 87, 181, 0.15); + padding: var(--space-s); + min-height: 50vh; +} + +.fhb-inline--editing .fhb-inline-form { + display: grid; + grid-template-columns: 1fr; + grid-template-rows: auto auto 1fr auto; + height: 100%; +} + + +/* ── Left side: name + content ─────────────────────────────────────────────── */ + +.fhb-inline-body { + min-width: 0; +} + +.fhb-inline-name { + font-size: var(--step--1); + font-weight: 600; + color: var(--accent-secondary); + margin-bottom: var(--space-3xs); +} + +/* ── Small rendered Markdown preview ──────────────────────────────────────── */ + +.fhb-md-preview { + font-size: var(--step--2); + color: var(--text-secondary); + line-height: 1.45; + max-height: 6em; + overflow: hidden; +} + +.fhb-md-preview p { + margin: 0; +} + +.fhb-md-preview p + p { + margin-top: var(--space-3xs); +} + +.fhb-md-preview ul, +.fhb-md-preview ol { + margin: var(--space-3xs) 0; + padding-left: var(--space-s); +} + +.fhb-md-preview li { + margin-bottom: 0; +} + +.fhb-md-preview strong { font-weight: 600; } +.fhb-md-preview em { font-style: italic; } +.fhb-md-preview code { + font-size: 0.9em; + background: var(--bg-secondary); + padding: 0 var(--space-4xs); + border-radius: 3px; +} + +.fhb-inline-empty { + font-size: var(--step--2); + color: var(--text-tertiary); + font-style: italic; +} + +/* ── Right side: edit button + live dot ───────────────────────────────────── */ + +.fhb-inline-actions { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: var(--space-2xs); + flex-shrink: 0; +} + +.fhb-toggle-form { + margin: 0; + line-height: 0; +} + +/* Live dot — green when on, red when off */ +.fhb-dot { + display: block; + width: 18px; + height: 18px; + border-radius: 50%; + border: none; + cursor: pointer; + padding: 0; + transition: opacity 0.15s; +} + +.fhb-dot:hover { + opacity: 0.7; +} + +.fhb-dot--on { + background: #2d6a4f; +} + +.fhb-dot--off { + background: #c0392b; +} + +/* ── Editor form ──────────────────────────────────────────────────────────── */ + +.fhb-edit-name-row { + margin-bottom: var(--space-xs); +} + +.fhb-edit-label { + display: block; + font-size: var(--step--2); + font-weight: 500; + color: var(--text-primary); + margin-bottom: var(--space-3xs); +} + +.fhb-name-input { + width: 100%; + max-width: 400px; + padding: var(--space-2xs) var(--space-xs); + border: 1px solid var(--border-primary); + border-radius: var(--radius); + font-size: var(--step--1); + font-family: var(--font-body); +} + +.fhb-name-input:focus { + outline: none; + border-color: var(--accent-primary); + box-shadow: 0 0 0 2px var(--accent-muted); +} + +.fhb-overtype-editor .--type-container { + border-radius: var(--radius); +} + +.fhb-edit-buttons { + display: grid; + grid-auto-flow: column; + grid-auto-columns: max-content; + gap: var(--space-xs); + margin-top: var(--space-xs); +} + +.fhb-inline-form { + margin-top: var(--space-xs); } diff --git a/app/public/assets/css/common.css b/app/public/assets/css/common.css index 02742ac..512eeff 100644 --- a/app/public/assets/css/common.css +++ b/app/public/assets/css/common.css @@ -3,346 +3,349 @@ *, *::before, *::after { - box-sizing: border-box; + box-sizing: border-box; } html, body { - margin: 0; - padding: 0; - height: 100%; - overflow: hidden; + margin: 0; + padding: 0; + height: 100%; + overflow: hidden; } body { - font-family: var(--font-body); - background: var(--bg-primary); - color: var(--text-primary); - background: linear-gradient( - 180deg, - rgba(0, 0, 0, 0) 92%, - rgba(149, 87, 181, 1) 100% - ); - display: flex; - flex-direction: column; + font-family: var(--font-body); + background: var(--bg-primary); + color: var(--text-primary); + background: linear-gradient( + 180deg, + rgba(0, 0, 0, 0) 92%, + rgba(149, 87, 181, 1) 100% + ); + display: flex; + flex-direction: column; } a { - color: inherit; - text-decoration: none; + color: inherit; + text-decoration: none; } a:hover { - text-decoration: none; + text-decoration-line: underline; + text-decoration-style: wavy; + text-decoration-thickness: 1px; } header { - vertical-align: center; - flex-shrink: 0; - background: linear-gradient( - 180deg, - var(--gradient-1) 0%, - var(--gradient-2) 33%, - var(--gradient-3) 66%, - var(--gradient-4) 100% - ); + vertical-align: center; + flex-shrink: 0; + background: linear-gradient( + 180deg, + var(--gradient-1) 0%, + var(--gradient-2) 33%, + var(--gradient-3) 66%, + var(--gradient-4) 100% + ); - .nav-logo { - text-decoration: none; - font-size: var(--step--1); + .nav-logo { + text-decoration: none; + } + + .nav-left-links, + .nav-right-links { + display: flex; + gap: var(--space-l); + align-items: center; + list-style: none; + margin: 0; + padding: 0; + } + + nav { + padding: var(--space-s) var(--space-s); + display: flex; + align-items: center; + justify-content: space-between; + font-size: var(--step-0); + + a { + font-family: var(--font-display); + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--accent-foreground); + text-decoration: none; + padding: var(--space-3xs) var(--space-xs); + border-radius: var(--radius); + text-shadow: + 0 0 16px var(--header-shadow-strong), + 0 0 32px var(--header-shadow-soft); } - .nav-left { - display: flex; - align-items: center; - gap: var(--space-l); + ul { + display: flex; + gap: var(--space-l); + align-items: center; + list-style: none; + margin: 0; + padding: 0; } - .nav-left-links, - .nav-right-links { - display: flex; - gap: var(--space-l); - align-items: center; - list-style: none; - margin: 0; - padding: 0; + ul a { + transition: opacity 0.15s; } - nav { - padding: var(--space-s) var(--space-s); - display: flex; - align-items: center; - justify-content: space-between; - font-size: var(--step-0); - - a { - font-family: var(--font-display); - letter-spacing: 0.12em; - text-transform: uppercase; - color: var(--accent-foreground); - text-decoration: none; - padding: var(--space-3xs) var(--space-xs); - border-radius: var(--radius); - text-shadow: - 0 0 16px var(--header-shadow-strong), - 0 0 32px var(--header-shadow-soft); - } - - ul { - display: flex; - gap: var(--space-l); - align-items: center; - list-style: none; - margin: 0; - padding: 0; - } - - ul a { - transition: opacity 0.15s; - } - - ul a:hover { - opacity: 1; - } + ul a:hover { + opacity: 1; } + } - ul a[aria-current="page"] { - opacity: 1; - border-bottom: 1px solid var(--header-nav-active-border); - padding-bottom: 1px; - } + ul a[aria-current="page"] { + opacity: 1; + border-bottom: 1px solid var(--header-nav-active-border); + padding-bottom: 1px; + } - /* nav-top-row: transparent wrapper at desktop — children become - direct flex items of nav, preserving the existing layout */ - .nav-top-row { - display: contents; - } + /* nav-top-row: transparent wrapper at desktop - children become + direct flex items of nav, preserving the existing layout */ + .nav-top-row { + display: contents; + } - /* nav-mobile-links: mobile-only dropdown, hidden at desktop */ - .nav-mobile-links { - display: none; /* overridden to block inside the mobile media query */ - } + /* nav-mobile-links: mobile-only dropdown, hidden at desktop */ + .nav-mobile-links { + display: none; /* overridden to block inside the mobile media query */ + } } /* ============================================================ - HAMBURGER MENU — public nav (pure CSS, checkbox trick) + HAMBURGER MENU - public nav (pure CSS, checkbox trick) DOM order inside
(public only): input.menu-btn ← off-screen checkbox nav - div.nav-top-row ← always-visible row (logo + burger) - div.nav-left ← logo + desktop link list - ul.nav-right-links ← desktop right links + div.nav-top-row ← always-visible row + ul.nav-left-links ← logo + Répertoire + ul.nav-right-links ← Licences, À Propos label.menu-icon ← burger icon trigger ul.nav-mobile-links ← full dropdown (hidden by default) At desktop: .menu-icon and .nav-mobile-links are display:none. .nav-top-row is display:contents so its children - participate directly in nav’s flex row. + participate directly in nav's flex row. At mobile: nav becomes a flex column. .nav-top-row is a real flex row (logo | burger). .nav-mobile-links expands via max-height on checkbox:checked. ============================================================ */ -/* Off-screen checkbox — triggered by its label */ +/* Off-screen checkbox - triggered by its label */ .menu-btn { - position: absolute; - top: -9999px; - left: -9999px; + position: absolute; + top: -9999px; + left: -9999px; } -/* Burger label — takes no space at desktop */ +/* Burger label - takes no space at desktop */ .menu-icon { - display: none; - cursor: pointer; - padding: var(--space-2xs) var(--space-s); - align-items: center; - justify-content: center; + display: none; + cursor: pointer; + padding: var(--space-2xs) var(--space-s); + align-items: center; + justify-content: center; } /* Middle bar of the burger icon */ .navicon { - background: var(--accent-foreground); - display: block; - height: 2px; - width: 24px; - position: relative; - transition: all 0.3s ease-out; + background: var(--accent-foreground); + display: block; + height: 2px; + width: 24px; + position: relative; + transition: all 0.3s ease-out; } /* Top and bottom bars */ .navicon::before, .navicon::after { - content: ""; - background: var(--accent-foreground); - display: block; - height: 2px; - width: 100%; - position: absolute; - transition: all 0.3s ease-out; + content: ""; + background: var(--accent-foreground); + display: block; + height: 2px; + width: 100%; + position: absolute; + transition: all 0.3s ease-out; } .navicon::before { - top: -7px; + top: -7px; } .navicon::after { - bottom: -7px; + bottom: -7px; } /* ---- Mobile ---- */ @media screen and (max-width: 640px) { - /* Nav becomes a flex column: top-row on row 1, dropdown on row 2 */ - header nav[aria-label="Navigation principale"] { - display: flex; - flex-direction: column; - align-items: stretch; - padding: 0; - } + /* Nav becomes a flex column: top-row on row 1, dropdown on row 2 */ + header nav[aria-label="Navigation principale"] { + display: flex; + flex-direction: column; + align-items: stretch; + padding: 0; + } - /* Top row: logo left, hamburger right */ - header nav[aria-label="Navigation principale"] .nav-top-row { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--space-s); - } + /* Top row: logo left, hamburger right */ + header nav[aria-label="Navigation principale"] .nav-top-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-s); + } - /* Hide desktop link lists inside the top row */ - header nav[aria-label="Navigation principale"] .nav-left-links, - header nav[aria-label="Navigation principale"] .nav-right-links { - display: none; - } + /* Hide desktop link lists inside the top row, but keep the logo visible */ + header nav[aria-label="Navigation principale"] .nav-right-links { + display: none; + } + header nav[aria-label="Navigation principale"] .nav-left-links { + display: flex; + gap: 0; + } + header nav[aria-label="Navigation principale"] + .nav-left-links + li:not(:first-child) { + display: none; + } - /* Reveal the hamburger icon */ - .menu-icon { - display: flex; - } + /* Reveal the hamburger icon */ + .menu-icon { + display: flex; + } - /* Dropdown: shown as block but clipped to zero height by default */ - header nav[aria-label="Navigation principale"] .nav-mobile-links { - display: block; /* override the desktop display:none */ - list-style: none; - margin: 0; - padding: 0; - max-height: 0; - overflow: hidden; - transition: max-height 0.2s ease-out; - } + /* Dropdown: shown as block but clipped to zero height by default */ + header nav[aria-label="Navigation principale"] .nav-mobile-links { + display: block; /* override the desktop display:none */ + list-style: none; + margin: 0; + padding: 0; + max-height: 0; + overflow: hidden; + transition: max-height 0.2s ease-out; + } - /* ---- Open state ---- */ - .menu-btn:checked - ~ nav[aria-label="Navigation principale"] - .nav-mobile-links { - max-height: 300px; - } + /* ---- Open state ---- */ + .menu-btn:checked + ~ nav[aria-label="Navigation principale"] + .nav-mobile-links { + max-height: 300px; + } - /* Dropdown link rows */ - header nav[aria-label="Navigation principale"] .nav-mobile-links li { - border-top: 1px solid var(--header-nav-active-border); - text-align: left; - } + /* Dropdown link rows */ + header nav[aria-label="Navigation principale"] .nav-mobile-links li { + border-top: 1px solid var(--header-nav-active-border); + text-align: left; + } - header nav[aria-label="Navigation principale"] .nav-mobile-links li a { - display: block; - width: 100%; - padding: var(--space-s) var(--space-s); - text-align: left; - } + header nav[aria-label="Navigation principale"] .nav-mobile-links li a { + display: block; + width: 100%; + padding: var(--space-s) var(--space-s); + text-align: left; + } - /* ---- Animate burger → X ---- */ - .menu-btn:checked - ~ nav[aria-label="Navigation principale"] - .menu-icon - .navicon { - background: transparent; - } + /* ---- Animate burger → X ---- */ + .menu-btn:checked + ~ nav[aria-label="Navigation principale"] + .menu-icon + .navicon { + background: transparent; + } - .menu-btn:checked - ~ nav[aria-label="Navigation principale"] - .menu-icon - .navicon::before { - transform: rotate(-45deg); - top: 0; - } + .menu-btn:checked + ~ nav[aria-label="Navigation principale"] + .menu-icon + .navicon::before { + transform: rotate(-45deg); + top: 0; + } - .menu-btn:checked - ~ nav[aria-label="Navigation principale"] - .menu-icon - .navicon::after { - transform: rotate(45deg); - bottom: 0; - } + .menu-btn:checked + ~ nav[aria-label="Navigation principale"] + .menu-icon + .navicon::after { + transform: rotate(45deg); + bottom: 0; + } } main { - flex: 1; - min-height: 0; - overflow-wrap: anywhere; + flex: 1; + min-height: 0; + overflow-wrap: anywhere; } main * { - overflow-wrap: anywhere; - word-break: break-word; + overflow-wrap: anywhere; + word-break: break-word; } /* ============================================================ - HEADINGS — global scale, shared by admin + public pages + HEADINGS - global scale, shared by admin + public pages All headings use the same font size (slightly smaller than the previous h1). Individual page overrides for size have been removed so everything inherits from here. ============================================================ */ :where(h1, h2, h3, h4, h5, h6) { - font-family: var(--font-display); - font-size: var(--step-2); - font-weight: 400; - margin: 0 0 var(--space-l) 0; - line-height: 1.15; + font-family: var(--font-display); + font-size: var(--step-2); + font-weight: 400; + margin: 0 0 var(--space-l) 0; + line-height: 1.15; } /* ============================================================ SEARCH BAR (shared) ============================================================ */ .header-search-wrap { - padding: 0 0; - flex-shrink: 0; - background-color: var(--gradient-4); - background: linear-gradient(180deg, var(--gradient-4) 0%, #ffffffee 100%); + padding: 0 0; + flex-shrink: 0; + background-color: var(--gradient-4); + background: linear-gradient(180deg, var(--gradient-4) 0%, #ffffffee 100%); } .header-search-form { - width: 100%; + width: 100%; } .header-search-input-wrap { - position: relative; - display: flex; - align-items: center; + position: relative; + display: flex; + align-items: center; } .header-search-icon { - position: absolute; - left: var(--space-s); - width: 18px; - height: 18px; - stroke: var(--accent-primary); - pointer-events: none; + position: absolute; + left: var(--space-s); + width: 18px; + height: 18px; + stroke: var(--accent-primary); + pointer-events: none; } .header-search-input-wrap input { - width: 100%; - padding: var(--space-2xs) var(--space-s) !important; - padding-left: calc(18px + var(--space-l)) !important; - border: 1px solid var(--accent-primary) !important; - border-radius: var(--radius) !important; - background: var(--bg-primary) !important; - font-size: var(--step-0) !important; - color: var(--text-primary) !important; - font-family: inherit !important; + width: 100%; + padding: var(--space-2xs) var(--space-s) !important; + padding-left: calc(18px + var(--space-l)) !important; + border: 1px solid var(--accent-primary) !important; + border-radius: var(--radius) !important; + background: var(--bg-primary) !important; + font-size: var(--step-0) !important; + color: var(--text-primary) !important; + font-family: inherit !important; } .header-search-input-wrap input::placeholder { - color: var(--accent-primary) !important; + color: var(--accent-primary) !important; } /* ============================================================ @@ -351,355 +354,361 @@ main * { /* Visually-hidden but screen-reader-accessible */ .sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; } /* Skip-to-content link (visible only on keyboard focus) */ .skip-link { - position: absolute; - top: -999px; - left: 1rem; - z-index: 9999; - padding: var(--space-2xs) var(--space-s); - background: var(--accent-primary); - color: var(--text-primary); - font-size: var(--step--1); - font-weight: 600; - text-decoration: none; - border-radius: 0 0 4px 4px; + position: absolute; + top: -999px; + left: 1rem; + z-index: 9999; + padding: var(--space-2xs) var(--space-s); + background: var(--accent-primary); + color: var(--text-primary); + font-size: var(--step--1); + font-weight: 600; + text-decoration: none; + border-radius: 0 0 4px 4px; } .skip-link:focus { - top: 0; + top: 0; } /* Consistent keyboard-focus ring for all interactive elements */ :focus-visible { - outline: none; - box-shadow: 0 0 0 2px var(--accent-primary); + outline: none; + box-shadow: 0 0 0 2px var(--accent-primary); + border-radius: var(--radius); + padding: var(--space-3xs) var(--space-xs); } /* Respect user motion preferences */ @media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - transition-duration: 0.01ms !important; - animation-duration: 0.01ms !important; - } + *, + *::before, + *::after { + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + } } /* ============================================================ - FORM ELEMENTS — base input / select / textarea / button + FORM ELEMENTS - base input / select / textarea / button ============================================================ */ label { - display: block; - margin-bottom: var(--space-3xs); + display: block; + margin-bottom: var(--space-3xs); } -input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="hidden"]):not([type="submit"]):not([type="button"]):not([type="reset"]):not([type="color"]), +input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not( + [type="hidden"] +):not([type="submit"]):not([type="button"]):not([type="reset"]):not( + [type="color"] +), select, textarea { - font-family: inherit; - font-size: var(--step--1); - padding: var(--space-2xs) var(--space-xs); - border: 1px solid var(--border-primary); - border-radius: var(--radius); - background: transparent; - color: var(--text-primary); - transition: border-color 0.15s; + font-family: inherit; + font-size: var(--step--1); + padding: var(--space-2xs) var(--space-xs); + border: 1px solid var(--border-primary); + border-radius: var(--radius); + background: transparent; + color: var(--text-primary); + transition: border-color 0.15s; } -input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="hidden"]):not([type="submit"]):not([type="button"]):not([type="reset"]):not([type="color"]):focus, +input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not( + [type="hidden"] +):not([type="submit"]):not([type="button"]):not([type="reset"]):not( + [type="color"] +):focus, select:focus, textarea:focus { - outline: none; - border: 2px solid var(--accent-primary); + outline: none; + border: 2px solid var(--accent-primary); } input::placeholder, textarea::placeholder { - color: var(--text-tertiary); - font-size: var(--step--1); + color: var(--text-tertiary); + font-size: var(--step--1); } textarea { - resize: vertical; - min-height: 80px; - line-height: 1.5; + resize: vertical; + min-height: 80px; + line-height: 1.5; } select { - cursor: pointer; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23999' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 0.5rem center; - padding-right: 1.5rem; - -webkit-appearance: none; - appearance: none; + cursor: pointer; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23999' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.5rem center; + padding-right: 1.5rem; + -webkit-appearance: none; + appearance: none; } /* ============================================================ - BUTTONS — shared .btn base class + BUTTONS - shared .btn base class Targets both and