mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-01 16:43:37 +08:00
Merge branch 'fix/dialog-opaque-fill'
This commit is contained in:
commit
e972a362dc
@ -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(
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user