Merge branch 'fix/dialog-opaque-fill'

This commit is contained in:
程序员阿江(Relakkes) 2026-07-27 06:26:20 +08:00
commit e972a362dc
4 changed files with 147 additions and 6 deletions

View File

@ -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(
<Modal open onClose={vi.fn()} title="Provider">
<span>Provider form</span>
</Modal>,
)
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(
<Modal open onClose={vi.fn()} title="Provider">
<span>Provider form</span>
</Modal>,
)
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(

View File

@ -75,7 +75,7 @@ export function Modal({ open, onClose, title, children, width = 560, footer }: M
<div className="fixed inset-0 z-[var(--z-dialog)] flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-[var(--color-overlay-scrim)] transition-opacity duration-200"
className="absolute inset-0 bg-[var(--color-modal-scrim)] transition-opacity duration-200"
onClick={onClose}
/>
@ -83,7 +83,9 @@ export function Modal({ open, onClose, title, children, width = 560, footer }: M
<div
ref={dialogRef}
// 24px — the top of the handoff's corner scale, reserved for modals.
className="glass-panel relative rounded-[var(--radius-3xl)] max-h-[85vh] flex flex-col"
// `dialog-panel`, not `glass-panel`: the fill has to be opaque on its
// own rather than leaning on a blur that may never run.
className="dialog-panel relative rounded-[var(--radius-3xl)] max-h-[85vh] flex flex-col"
style={{ width, maxWidth: 'calc(100vw - 48px)' }}
role="dialog"
aria-modal="true"

View File

@ -1155,9 +1155,14 @@
--color-inspector-capacity: var(--cc-t3);
/* Overlays */
/* `--color-overlay-scrim` dims for non-modal layers (the mobile sidebar
drawer); `--color-modal-scrim` is the heavier one true modals sit on. */
--color-overlay-scrim: rgba(var(--cc-scrim-rgb), 0.42);
--color-modal-scrim: rgba(var(--cc-scrim-rgb), 0.35);
--color-surface-glass: rgba(var(--cc-bg-rgb), 0.84);
--color-modal-scrim: rgba(var(--cc-scrim-rgb), 0.5);
/* Dialogs are opaque: see `.dialog-panel`. Light themes put the paper at the
top of the ramp, so the lifted surface is the background value itself. */
--color-surface-dialog: var(--cc-bg);
--color-surface-glass: rgba(var(--cc-bg-rgb), 0.92);
--color-surface-glass-border: rgba(var(--cc-bd2-rgb), 0.4);
/* Shadows: card / composer / floating overlay */
@ -1289,8 +1294,11 @@
--color-diff-syntax-punctuation: #9B908C;
--color-selection-fg: var(--cc-t1);
--color-surface-glass: rgba(var(--cc-bg-rgb), 0.86);
--color-modal-scrim: rgba(var(--cc-scrim-rgb), 0.62);
/* Ink themes run the background at the bottom of the ramp, so a lifted
surface has to climb it `--cc-s1`, not the light themes' `--cc-bg`. */
--color-surface-dialog: var(--cc-s1);
--color-surface-glass: rgba(var(--cc-bg-rgb), 0.93);
--color-modal-scrim: rgba(var(--cc-scrim-rgb), 0.66);
--color-overlay-scrim: rgba(var(--cc-scrim-rgb), 0.56);
--shadow-button-primary: 0 2px 10px rgba(0, 0, 0, 0.4);
--color-activity-tooltip-border: rgba(var(--cc-bg-rgb), 0.28);
@ -1381,10 +1389,33 @@ button, input, textarea, select, a, [role="button"] {
box-shadow: var(--shadow-overlay);
}
/* The fill above is translucent on the assumption that the blur will scramble
whatever shows through. Where `backdrop-filter` is unavailable that
assumption fails silently no fallback fires, the blur is simply skipped,
and page text stays legible straight through every dropdown and popover.
Going opaque costs the glass look only on engines that never rendered it. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.glass-panel {
background: rgb(var(--cc-bg-rgb));
}
}
.glass-panel:focus-within {
box-shadow: var(--shadow-focus-ring), var(--shadow-dropdown);
}
/* Modals are read-and-fill surfaces, not glances: an 860px provider form that
scrolls needs the page behind it gone, not softened. `.glass-panel` couples
its fill to `backdrop-filter`, and a dialog this large cannot take the bet
that the blur is live when it is not, the page reads through the form.
No `:focus-within` ring: a dialog holds focus essentially always, so the
glass variant's ring would burn permanently rather than signal anything. */
.dialog-panel {
background: var(--color-surface-dialog);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-overlay);
}
/* The composer sits closer to the page than a floating overlay does, so it
takes the middle step of the shadow scale. Declared as a class rather than a
`shadow-[]` utility because `.glass-panel` also sets `box-shadow`, and which

View File

@ -114,6 +114,7 @@ describe('desktop theme tokens', () => {
'--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')