From e43a70f9a11b5cb5218352a56938fecdb101d13d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 27 Jul 2026 06:09:50 +0800 Subject: [PATCH] fix(desktop): give dialogs an opaque fill instead of betting on the blur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider list behind the 860px "add provider" dialog was legible straight through the panel — URLs and model names readable in the form's empty space. `.glass-panel` states a translucent fill and a blur in one rule, and reads as frosted only when both land. The blur is the fragile half: where `backdrop-filter` does not run there is no failure for CSS to report. The declaration is skipped, the 0.84 fill is left standing on its own, and 16% of the page comes through unscrambled. A reduced repro pins it — with the blur live nothing inside the panel is readable; with it disabled the result matches the report exactly. Dialogs leave the coupling entirely. `--color-surface-dialog` is opaque by construction (no alpha channel to walk back one decimal at a time), mapping to `--cc-bg` on light themes and `--cc-s1` on the ink ones — ink runs its background at the bottom of the ramp, so a lifted surface has to climb it rather than reuse it. `.dialog-panel` carries fill, hairline and shadow and never touches `backdrop-filter`. Two things follow from an opaque panel. The scrim is now the only thing separating the dialog from the page, so `Modal` moves to the heavier `--color-modal-scrim` — the token that already existed for exactly this and had only `GlobalSearchModal` as a user, while `Modal` sat on the non-modal one. And the `:focus-within` ring goes: a dialog holds focus essentially always, so it burned permanently rather than signalling anything. The small floating layers keep the glass, but no longer depend on the blur for legibility: the fill goes to 0.92/0.93. The `@supports` fallback added alongside it is worth less than it looks — it catches engines that do not implement `backdrop-filter`, not the failure seen here, where the query returns true and the blur still never runs. Raising the density is the half that actually covers the reported case. Why the blur is inert on this machine is not established. Ruled out on the code side: GPU switches, containment on `html`/`body`/`#root`, and CSS `zoom` (UI scaling goes through Electron's `setZoomFactor`). The fix does not depend on that answer. Verified across warm-classic, dark and ink-blue: computed fills come back as `rgb(...)` with no alpha, `backdrop-filter: none`, and both ink themes render the panel lighter than the page behind it. --- desktop/src/components/ui/Modal.test.tsx | 28 +++++++++ desktop/src/components/ui/Modal.tsx | 6 +- desktop/src/theme/globals.css | 39 ++++++++++-- desktop/src/theme/globals.test.ts | 80 ++++++++++++++++++++++++ 4 files changed, 147 insertions(+), 6 deletions(-) diff --git a/desktop/src/components/ui/Modal.test.tsx b/desktop/src/components/ui/Modal.test.tsx index 315265e5..35f1c588 100644 --- a/desktop/src/components/ui/Modal.test.tsx +++ b/desktop/src/components/ui/Modal.test.tsx @@ -1,3 +1,4 @@ +import '@testing-library/jest-dom' import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { ConfirmDialog } from './ConfirmDialog' @@ -20,6 +21,33 @@ describe('Modal', () => { expect(document.body.contains(dialog)).toBe(true) }) + it('carries an opaque panel rather than the blur-dependent glass fill', () => { + // `.glass-panel` only hides what is behind it when `backdrop-filter` runs. + // Where it does not, the blur is skipped without error and the page reads + // through the panel — which is how the provider list stayed legible behind + // the provider form. Dialogs are too large and too long-lived to bet on it. + render( + + Provider form + , + ) + + const dialog = screen.getByRole('dialog', { name: 'Provider' }) + expect(dialog).toHaveClass('dialog-panel') + expect(dialog).not.toHaveClass('glass-panel') + }) + + it('sits on the modal scrim, not the lighter non-modal one', () => { + render( + + Provider form + , + ) + + const backdrop = screen.getByRole('dialog', { name: 'Provider' }).previousElementSibling + expect(backdrop).toHaveClass('bg-[var(--color-modal-scrim)]') + }) + it('closes when the backdrop is clicked', () => { const onClose = vi.fn() render( diff --git a/desktop/src/components/ui/Modal.tsx b/desktop/src/components/ui/Modal.tsx index ffacda33..83a0e749 100644 --- a/desktop/src/components/ui/Modal.tsx +++ b/desktop/src/components/ui/Modal.tsx @@ -75,7 +75,7 @@ export function Modal({ open, onClose, title, children, width = 560, footer }: M
{/* Backdrop */}
@@ -83,7 +83,9 @@ export function Modal({ open, onClose, title, children, width = 560, footer }: M
{ '--color-brand-hover', '--color-border-focus', '--color-surface-selected', + '--color-surface-dialog', '--color-switch-checked-bg', '--color-switch-thumb', '--color-btn-primary-bg', @@ -262,6 +263,85 @@ describe('desktop theme tokens', () => { }) }) +/** + * `.glass-panel` states a translucent fill and a blur in one rule, and reads as + * frosted only when both land. The blur is the fragile half: where + * `backdrop-filter` does not run there is no failure for CSS to report — the + * declaration is simply skipped, the fill stays translucent on its own, and + * page text reads straight through the panel. That is what put a legible + * provider list behind the 860px provider form. + * + * These pin the two defenses. Dialogs opted out of the coupling entirely; + * the small floating layers kept it but no longer depend on it for legibility. + */ +describe('overlay opacity contract', () => { + /** Every value assigned to `token` across the stylesheet, in source order. */ + function declarationsOf(token: string) { + const pattern = new RegExp(`${token}:\\s*([^;]+);`, 'g') + const values = [...normalizedCss.matchAll(pattern)].map((match) => match[1]!.trim()) + expect(values.length, `${token} should be declared`).toBeGreaterThan(0) + return values + } + + function alphaOf(declaration: string) { + const match = declaration.match(/,\s*([0-9.]+)\s*\)\s*$/) + expect(match, `expected a trailing alpha in "${declaration}"`).not.toBeNull() + return Number(match![1]) + } + + it('gives dialogs a fill with no alpha channel at all, in every theme', () => { + // Not "mostly opaque" — an rgba() here would put the regression back one + // decimal at a time. + for (const value of declarationsOf('--color-surface-dialog')) { + expect(value, `dialog fill should be opaque, got "${value}"`).not.toMatch(/rgba|hsla/) + expect(value).not.toMatch(/,\s*[0-9.]+\s*\)\s*$/) + } + }) + + it('lifts the dialog fill off the ground on both light and ink themes', () => { + // Light palettes top out at `--cc-bg`; ink palettes bottom out there, so a + // shared value would sink dark dialogs into the page behind them. + const values = declarationsOf('--color-surface-dialog') + expect(values).toContain('var(--cc-bg)') + expect(values).toContain('var(--cc-s1)') + }) + + it('keeps the glass fill dense enough to stand without the blur', () => { + // At 0.84 the page was readable through unblurred glass. These floating + // layers are small enough that the frosted look survives the extra density. + for (const value of declarationsOf('--color-surface-glass')) { + expect(alphaOf(value), `glass fill "${value}" is too sheer`).toBeGreaterThanOrEqual(0.9) + } + }) + + it('drops glass to a fully opaque fill where backdrop-filter is unavailable', () => { + const fallback = normalizedCss.match( + /@supports not \(\(backdrop-filter[^)]*\)[^{]*\{\s*\.glass-panel\s*\{([^}]*)\}/, + ) + expect(fallback, 'expected an @supports fallback for .glass-panel').not.toBeNull() + expect(fallback![1]).toMatch(/background:\s*rgb\(var\(--cc-bg-rgb\)\)/) + }) + + it('keeps the dialog panel off backdrop-filter entirely', () => { + const rule = normalizedCss.match(/\n\.dialog-panel \{([^}]*)\}/) + expect(rule, 'expected a .dialog-panel rule').not.toBeNull() + expect(rule![1]).not.toMatch(/backdrop-filter/) + expect(rule![1]).toMatch(/background:\s*var\(--color-surface-dialog\)/) + }) + + it('dims harder behind a modal than behind a non-modal drawer', () => { + // Now that the panel is opaque the scrim is the only thing separating the + // dialog from the page, so it carries more weight than the sidebar's. + const modal = declarationsOf('--color-modal-scrim').map(alphaOf) + const overlay = declarationsOf('--color-overlay-scrim').map(alphaOf) + expect(modal).toHaveLength(overlay.length) + modal.forEach((alpha, index) => { + expect(alpha, 'modal scrim should be at least as heavy as the overlay scrim') + .toBeGreaterThanOrEqual(overlay[index]!) + }) + }) +}) + describe('layering scale', () => { const scale = (() => { const start = normalizedCss.indexOf('/* ─── Layering scale')