From 1dd389563c159d9ac82f4583ba44ccb9941ca90c Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:42:01 +0000 Subject: [PATCH] style(client): unify modal backdrops with dedicated scrim tokens Replace tinted modal veils with dedicated scrim tokens so dialogs, sheets, and image lightboxes dim consistently in both light and dark themes. Enforce scrim token usage in style validation to prevent backdrops that wash out in dark mode. --- client/scripts/validate-styles.mjs | 26 ++++++++++++++++++- client/src/assets/theme/bridge.css | 2 ++ client/src/assets/theme/tokens.css | 10 +++++++ client/src/components/legal/LegalNotices.vue | 2 +- client/src/components/ui/ConfirmDialog.vue | 2 +- .../ui/__tests__/ConfirmDialog.spec.ts | 2 +- .../src/components/ui/sheet/SheetOverlay.vue | 2 +- client/src/features/admin/ResetLinkModal.vue | 2 +- .../features/auth/ChangePasswordDialog.vue | 2 +- .../author/components/AuthorConfirmDialog.vue | 2 +- .../author/components/AuthorHeader.vue | 2 +- .../components/BookDockBulkEditDialog.vue | 2 +- .../components/BookDockCoverPeek.vue | 2 +- .../components/BookDockFileSheet.vue | 2 +- .../components/BookDockFinalizeDialog.vue | 2 +- .../BookDockSetDestinationDialog.vue | 2 +- .../AuthorEnrichmentReportModal.vue | 2 +- .../BookMetadataFetchReportModal.vue | 2 +- .../components/GrabReleaseDialog.vue | 2 +- .../book/components/BookCoverLightbox.vue | 2 +- .../book/components/BookQuickView.vue | 2 +- .../components/BulkEditMetadataDialog.vue | 2 +- .../book/components/DeleteBookDialog.vue | 2 +- .../components/KeyboardShortcutOverlay.vue | 7 +---- .../book/components/MetadataExportDialog.vue | 2 +- .../components/ResetReadingStateDialog.vue | 2 +- .../detail/tabs/AddBookFileModal.vue | 2 +- .../detail/tabs/AddSessionDialog.vue | 2 +- .../book/components/detail/tabs/FilesTab.vue | 4 +-- .../detail/tabs/MetadataDiffRow.vue | 2 +- .../book/components/table/BookCoverDialog.vue | 2 +- .../components/CreateCollectionDialog.vue | 2 +- .../components/EditCollectionDialog.vue | 2 +- .../components/IconUploadDialog.vue | 2 +- .../features/email/components/GroupsTab.vue | 2 +- .../features/email/components/HistoryTab.vue | 2 +- .../email/components/ProvidersTab.vue | 2 +- .../email/components/RecipientsTab.vue | 2 +- .../email/components/SendBookDialog.vue | 2 +- .../email/components/TemplatesTab.vue | 2 +- .../__tests__/SendBookDialog.spec.ts | 2 +- .../components/HardcoverImportReviewModal.vue | 2 +- .../library/components/BookUploadModal.vue | 2 +- .../library/components/FolderPickerModal.vue | 6 +---- .../components/LibraryCreatorModal.vue | 2 +- .../migration/components/MigrationModal.vue | 2 +- .../reader/audiobook/AudiobookReaderView.vue | 4 +-- .../components/KeyboardShortcutsModal.vue | 2 +- .../pdf-v4/components/PdfReaderContent.vue | 2 +- .../reader/shared/components/NoteDialog.vue | 2 +- .../settings/KoreaderCredentialsDialog.vue | 2 +- .../features/settings/KoreaderSettings.vue | 12 ++++----- .../features/settings/MagicLinksSettings.vue | 2 +- client/src/features/settings/OpdsSettings.vue | 2 +- .../CustomMetadataSettings.vue | 2 +- .../components/CreateSmartScopeDialog.vue | 2 +- .../components/SaveAsSmartScopeDialog.vue | 2 +- .../components/SmartScopeEditorPanel.vue | 2 +- .../components/BulkRenameConfirmDialog.vue | 2 +- .../components/BulkRenameRunDialog.vue | 2 +- .../components/BrowseMergeModal.vue | 2 +- .../components/BulkDeleteModal.vue | 2 +- .../entity-manager/components/DeleteModal.vue | 2 +- .../entity-manager/components/RenameModal.vue | 2 +- .../entity-manager/components/SplitModal.vue | 2 +- .../features/tts/TtsOpenAiVoiceCuration.vue | 2 +- .../src/features/whats-new/WhatsNewDialog.vue | 2 +- .../whats-new/components/ImageLightbox.vue | 2 +- 68 files changed, 109 insertions(+), 82 deletions(-) diff --git a/client/scripts/validate-styles.mjs b/client/scripts/validate-styles.mjs index 83451ea32..91adfe465 100644 --- a/client/scripts/validate-styles.mjs +++ b/client/scripts/validate-styles.mjs @@ -32,6 +32,19 @@ const TUNED_TEXT_TOKENS = [ // (`hover:`, `placeholder:`, `group-data-[active=true]/item:` and so on). const FADED_TEXT_PATTERN = new RegExp(String.raw`(?:[\w[\]=.\-/]+:)*text-(?:${TUNED_TEXT_TOKENS.join('|')})\/\d+`, 'g') const LEGACY_SETTINGS_BUTTON_PATTERN = /\bsettings-btn-(?:primary|outline|secondary|danger)\b/g + +/** + * Modal backdrops take their veil from `bg-scrim`, or `bg-scrim-media` behind image viewers, so + * every dialog and sheet dims the page the same way in both themes. A backdrop tinted with a theme + * colour inverts in dark mode: `--foreground` is near-white there, so the veil washes the page grey + * instead of dimming it. A full-screen layer counts as a backdrop when it is fixed or dismisses on + * click; tinted overlays that only decorate a cover or card are left alone. + */ +const ELEMENT_OPENING_TAG_PATTERN = /<[A-Za-z][\w-]*\b[^<>]*>/g +const DIALOG_OVERLAY_TAG_PATTERN = /^<(?:Dialog|AlertDialog)Overlay\b/ +const SCRIM_CLASS_PATTERN = /\bbg-scrim(?:-media)?\b/ +const TINTED_BACKDROP_PATTERN = /\bbg-(?:black|white|foreground|background)\/\d+/ +const BACKDROP_ROLE_PATTERN = /\bfixed\b|@click/ const BUTTON_OPENING_TAG_PATTERN = //g const DESTRUCTIVE_BUTTON_VARIANT_PATTERN = /\bvariant=["']destructive(?:-outline|-ghost)?["']/ const DESTRUCTIVE_BUTTON_COLOR_OVERRIDE_PATTERN = @@ -74,6 +87,17 @@ for (const file of await sourceFiles(sourceDirectory)) { errors.push(`${relativePath}:${line}: destructive Button color override - keep destructive styling centralized in the shared Button variants.`) } + for (const match of source.matchAll(ELEMENT_OPENING_TAG_PATTERN)) { + const tag = match[0] + const isDialogOverlay = DIALOG_OVERLAY_TAG_PATTERN.test(tag) + const isTintedBackdrop = /\binset-0\b/.test(tag) && BACKDROP_ROLE_PATTERN.test(tag) && TINTED_BACKDROP_PATTERN.test(tag) + if ((!isDialogOverlay || SCRIM_CLASS_PATTERN.test(tag)) && !isTintedBackdrop) continue + const line = source.slice(0, match.index).split('\n').length + errors.push( + `${relativePath}:${line}: modal backdrop - use bg-scrim, or bg-scrim-media behind an image viewer, so it dims the page in both themes.`, + ) + } + for (const [index, line] of lines.entries()) { for (const match of line.matchAll(LEGACY_SETTINGS_BUTTON_PATTERN)) { errors.push(`${relativePath}:${index + 1}: ${match[0]} - use the shared Button component and its variants.`) @@ -99,4 +123,4 @@ if (errors.length > 0) { throw new Error(`Style validation failed:\n${errors.join('\n')}`) } -console.log('Validated style tokens and shared settings buttons') +console.log('Validated style tokens, modal backdrops and shared settings buttons') diff --git a/client/src/assets/theme/bridge.css b/client/src/assets/theme/bridge.css index fd2f3355a..daf165b8c 100644 --- a/client/src/assets/theme/bridge.css +++ b/client/src/assets/theme/bridge.css @@ -65,6 +65,8 @@ --color-diff-del: var(--diff-del); --color-diff-ins: var(--diff-ins); + --color-scrim: var(--scrim); + --color-scrim-media: var(--scrim-media); --color-path-dim: var(--path-dim); --color-path-fold: var(--path-fold); diff --git a/client/src/assets/theme/tokens.css b/client/src/assets/theme/tokens.css index 449215383..09674cd08 100644 --- a/client/src/assets/theme/tokens.css +++ b/client/src/assets/theme/tokens.css @@ -102,6 +102,13 @@ --surface-3: oklch(0.93 var(--tint-c-surface) var(--tint-h)); --surface-4: oklch(0.91 var(--tint-c-surface) var(--tint-h)); + /* Modal scrims: the veil behind every dialog and sheet. Dark in both themes. Tinting it with the + foreground colour washed dark mode out to grey instead of dimming it, because there the + foreground is near-white. --scrim-media sits behind image viewers, where the picture is the + point, so it is near-opaque in both themes. */ + --scrim: oklch(0.2 0.02 var(--tint-h) / 0.35); + --scrim-media: oklch(0 0 0 / 0.82); + /* Shadows */ --elevation-xs: 0 1px 2px oklch(0 0 0 / 0.05); --elevation-sm: 0 1px 3px oklch(0 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.06); @@ -362,6 +369,9 @@ --surface-3: oklch(calc(0.245 + var(--bg-lift)) var(--tint-c-surface) var(--tint-h)); --surface-4: oklch(calc(0.295 + var(--bg-lift)) var(--tint-c-surface) var(--tint-h)); + /* A dark surface needs a deeper veil than a light one to read as dimmed at all. */ + --scrim: oklch(0.06 0.01 var(--tint-h) / 0.62); + /* Shadows */ --elevation-xs: 0 1px 2px oklch(0 0 0 / 0.4); --elevation-sm: 0 1px 3px oklch(0 0 0 / 0.5), 0 1px 2px oklch(0 0 0 / 0.4); diff --git a/client/src/components/legal/LegalNotices.vue b/client/src/components/legal/LegalNotices.vue index 61569f3bb..a29ec75f3 100644 --- a/client/src/components/legal/LegalNotices.vue +++ b/client/src/components/legal/LegalNotices.vue @@ -15,7 +15,7 @@ const ADDITIONAL_TERMS_URL = `${PROJECT_URL}/blob/main/ADDITIONAL_TERMS.md`