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`