From 5c15fd9f60e74a347e843f52edcc9a0529d7eaed Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 12 Aug 2026 14:32:52 -0600 Subject: [PATCH 1/2] feat(ui): tint the mobile browser chrome to match the dialog scrim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While a dialog is open, tints the browser's own chrome so it reads as one continuous surface rather than a dimmed page inside undimmed furniture. Two surfaces move together: `` for the address bar and toolbar, and ``'s background for the canvas outside the layout viewport — the overscroll gutter and the strip revealed as the address bar collapses, neither of which a `position: fixed` scrim covers. It ships no colour of its own. The target is derived — the backdrop's computed background composited over whatever the page already had — so it stays correct if a consumer retunes the scrim. The meta is prepended rather than mutated, so it overrides the app's own (including framework-managed tags like Next's `viewport.themeColor`) and removing it restores theirs with no bookkeeping. Refcounted across stacked dialogs, reverted exactly on close, and inert wherever `theme-color` is ignored. Opt out with `syncBrowserChrome={false}`. Split out of #9388: `animate` writes `document.body.style.backgroundColor` on every frame, which is a full-viewport repaint per frame during the entrance. That wants profiling on real mobile hardware before it ships. If it measures badly, the fix is to set the body colour once at the target rather than animating it — it is only ever visible outside the layout viewport, so nobody sees it mid-entrance. --- .../swingset/src/stories/dialog.component.mdx | 17 + .../components/dialog/browser-chrome.ts | 342 ++++++++++++++++++ .../mosaic/components/dialog/dialog.test.tsx | 105 ++++++ .../src/mosaic/components/dialog/dialog.tsx | 103 +++++- 4 files changed, 563 insertions(+), 4 deletions(-) create mode 100644 packages/ui/src/mosaic/components/dialog/browser-chrome.ts diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx index ebe7b975a50..9dcf9b4f578 100644 --- a/packages/swingset/src/stories/dialog.component.mdx +++ b/packages/swingset/src/stories/dialog.component.mdx @@ -26,6 +26,7 @@ primitive's focus trapping, scroll lock, and ARIA wiring. { name: 'onOpenChange', type: '(open: boolean, details: DialogOpenChangeDetails) => void' }, { name: 'modal', type: 'boolean', default: 'true' }, { name: 'closedBy', type: "'any' | 'closerequest' | 'none'", default: "'any'" }, + { name: 'syncBrowserChrome', type: 'boolean', default: 'true' }, ]} /> @@ -234,6 +235,22 @@ than the entrance. The popup scales from its own centre. Under `prefers-reduced-motion: reduce` only `transform` drops out — the fade still runs, since the vestibular concern is the movement. +### Browser chrome + +On a phone an open dialog also tints the browser's own chrome, so it reads as one continuous +surface rather than a dimmed page inside undimmed furniture. Two things move together: +`` (the address bar and toolbar) and ``'s background (the overscroll +gutter and the strip revealed as the address bar collapses — areas a `position: fixed` scrim cannot +cover). + +It is on by default and holds no opinion: the colour is derived from the backdrop's computed +background composited over the page's own, the meta is prepended rather than mutated (so it +overrides the app's tag — including framework-managed ones like Next's `viewport.themeColor` — +and removal restores it), the body colour is saved and restored, and stacked dialogs share one +meta. + +Pass `syncBrowserChrome={false}` if the app drives `theme-color` itself. + ### On-screen keyboards iOS shrinks the visual viewport when the keyboard opens but leaves layout alone, so a diff --git a/packages/ui/src/mosaic/components/dialog/browser-chrome.ts b/packages/ui/src/mosaic/components/dialog/browser-chrome.ts new file mode 100644 index 00000000000..b4aaeca91cd --- /dev/null +++ b/packages/ui/src/mosaic/components/dialog/browser-chrome.ts @@ -0,0 +1,342 @@ +/** + * Tints the mobile browser's own chrome to match the dialog's scrim, so an open overlay reads as + * one continuous surface instead of a dimmed page inside undimmed browser furniture. + * + * Two surfaces have to move together: + * + * - `` tints the address bar and toolbar on iOS Safari and Chrome/Firefox + * for Android. + * - ``'s background propagates to the CANVAS (per CSS, when `` has none of its own), + * which is what paints everything OUTSIDE the layout viewport: the rubber-band overscroll gutter, + * the strip revealed as the address bar collapses, and the area behind the home indicator. A + * `position: fixed` scrim covers none of those, so without this the app's original colour shows + * through at the edges as an undimmed band. + * + * Nothing here is an opinion. The target colour is DERIVED — the backdrop's own computed + * background composited over whatever the page already had — so this ships no colour, and stays + * correct if a consumer retunes the scrim. It reverts exactly, and it is inert on platforms that + * ignore `theme-color`. + */ + +/** How the page looked before any dialog opened. Captured once, on the first open. */ +interface Snapshot { + /** The meta we inserted, so teardown removes exactly ours. */ + meta: HTMLMetaElement; + /** ``'s own inline background, restored verbatim (including "not set"). */ + bodyBackground: string; + /** The colour the chrome had before we touched it, and what we composite over. */ + base: string; +} + +/** One open dialog's contribution to the tint. */ +interface Layer { + /** The backdrop's computed background, composited over whatever is beneath it. */ + scrim: string; + /** + * The backdrop's LIVE computed style, not a snapshot of its timing. + * + * `getComputedStyle` returns a live object, and that matters twice. The backdrop's duration + * differs by direction — shorter leaving than arriving — so reading it at each use gets the + * right one for free. And it is `0s` on the entering frame, where the headless layer sets an + * inline `transition: none`; a value captured there would make every fade a snap. + */ + styles: CSSStyleDeclaration; +} + +/** Read at each use, never cached — see `Layer.styles`. */ +const layerDuration = (layer: Layer) => firstDuration(layer.styles.transitionDuration); +const layerEase = (layer: Layer) => makeEasing(layer.styles.transitionTimingFunction); + +let snapshot: Snapshot | null = null; +/** + * The open dialogs, outermost first. A STACK rather than a count, because the tint has to be + * reversible: closing a nested dialog must return the chrome to what the dialogs still open + * compose to, which a counter cannot reconstruct. Recomputing from the base every time also makes + * the result independent of the order things happened in. + */ +const layers: Layer[] = []; +let frame = 0; +/** + * The deferred teardown, so it can be CANCELLED if a dialog opens again before it fires. + * + * Without this, closing schedules a `finish` that removes the meta after the fade — and anything + * that re-opens inside that window (a second dialog, or React StrictMode's mount → cleanup → + * mount in dev) gets its tint torn out from under it a beat later. The symptom is a dialog that + * tints the chrome correctly and then reverts to the page's own colour while still open. + */ +let teardown = 0; + +/** + * A 1x1 scratch canvas, used as the colour engine. Created lazily and reused. + */ +let scratch: CanvasRenderingContext2D | null | undefined; +function context(): CanvasRenderingContext2D | null { + if (scratch === undefined) { + const canvas = document.createElement('canvas'); + canvas.width = 1; + canvas.height = 1; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + // Feature-detected rather than assumed: jsdom hands back a context object with none of the + // drawing methods on it, so a plain null check is not enough. Without a usable canvas the + // colour work is skipped entirely — the meta is still added and removed, so nothing else + // changes; only the tint is absent. + const usable = + typeof ctx?.clearRect === 'function' && + typeof ctx.fillRect === 'function' && + typeof ctx.getImageData === 'function'; + scratch = usable ? ctx : null; + } + return scratch; +} + +/** + * Resolves any CSS colour the browser can render to sRGB `[r, g, b]`, by painting it and reading + * the pixel back. + * + * Parsing the string ourselves is not an option, and the reason is worth stating: a computed + * colour is NOT necessarily `rgb()`. Our scrim serialises as `oklab(0 0 0 / 0.4)` and a light + * page's background as `oklab(1 0 0)` — where the three numbers are lightness and two opponent + * axes, not channels. Reading them positionally turns white into `rgb(1, 0, 0)`, i.e. black, which + * is exactly the bug this replaces. Canvas applies the real colour grammar and hands back sRGB. + * + * Returns `null` when the value is not a colour the canvas will take, which callers treat as "do + * nothing" — a colour we cannot resolve is not one to guess at. + */ +function readColor(input: string): [number, number, number] | null { + const ctx = context(); + if (!ctx || !input) { + return null; + } + // A sentinel that the input cannot coincidentally equal: if assignment is rejected, `fillStyle` + // keeps this value and we know the parse failed rather than silently painting the wrong colour. + ctx.fillStyle = '#010203'; + ctx.fillStyle = input; + if (ctx.fillStyle === '#010203' && input !== '#010203') { + return null; + } + ctx.clearRect(0, 0, 1, 1); + ctx.fillRect(0, 0, 1, 1); + const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data; + return [r, g, b]; +} + +/** + * The scrim over the page, composited by the canvas rather than by hand — so the scrim's alpha, + * its colour space, and the blend are all the browser's own arithmetic. + */ +function composite(under: string, over: string): [number, number, number] | null { + const ctx = context(); + if (!ctx || !readColor(under) || !readColor(over)) { + return null; + } + ctx.clearRect(0, 0, 1, 1); + ctx.fillStyle = under; + ctx.fillRect(0, 0, 1, 1); + ctx.fillStyle = over; + ctx.fillRect(0, 0, 1, 1); + const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data; + return [r, g, b]; +} + +const toCss = ([r, g, b]: [number, number, number]) => `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`; + +/** + * The colour the chrome already had. Prefers the app's own `theme-color` — honouring `media`, since + * an app may ship one per colour scheme and only the first MATCHING one applies — and falls back to + * the body's background, which is what a browser samples when no meta is present. + */ +function readBaseColor(): string { + const metas = document.head.querySelectorAll('meta[name="theme-color"]'); + for (const meta of metas) { + const media = meta.getAttribute('media'); + if (!media || window.matchMedia(media).matches) { + return meta.content; + } + } + return getComputedStyle(document.body).backgroundColor; +} + +/** + * A cubic-bézier sampler backed by a lookup table. + * + * Solving x→t exactly per frame is a Newton iteration on the main thread during the one animation + * the user is watching. Sampling the curve once into a table and interpolating between entries is a + * binary search instead, and at this resolution the error is far below a colour step. + */ +function makeEasing(spec: string): (t: number) => number { + const match = spec.match(/cubic-bezier\(([^)]+)\)/); + if (!match) { + return t => t; + } + const [x1, y1, x2, y2] = match[1].split(',').map(Number); + if ([x1, y1, x2, y2].some(Number.isNaN)) { + return t => t; + } + const axis = (p1: number, p2: number, t: number) => { + const u = 1 - t; + return 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t; + }; + const SAMPLES = 32; + const table = Array.from({ length: SAMPLES + 1 }, (_, i) => axis(x1, x2, i / SAMPLES)); + return (x: number) => { + let lo = 0; + while (lo < SAMPLES && table[lo + 1] < x) { + lo++; + } + const span = table[lo + 1] - table[lo]; + const t = (lo + (span > 0 ? (x - table[lo]) / span : 0)) / SAMPLES; + return axis(y1, y2, t); + }; +} + +/** Seconds from the first entry of a computed `transition-duration` list. */ +const firstDuration = (value: string) => { + const first = value.split(',')[0].trim(); + const n = parseFloat(first); + return Number.isNaN(n) ? 0 : first.endsWith('ms') ? n : n * 1000; +}; + +function animate(from: string, to: string, durationMs: number, ease: (t: number) => number) { + cancelAnimationFrame(frame); + const apply = (value: string) => { + if (!snapshot) { + return; + } + snapshot.meta.content = value; + document.body.style.backgroundColor = value; + }; + const a = readColor(from); + const b = readColor(to); + if (!a || !b || durationMs <= 0) { + apply(to); + return; + } + const start = performance.now(); + const step = () => { + if (!snapshot) { + return; + } + const p = Math.min(1, (performance.now() - start) / durationMs); + const e = ease(p); + apply(toCss([a[0] + (b[0] - a[0]) * e, a[1] + (b[1] - a[1]) * e, a[2] + (b[2] - a[2]) * e])); + if (p < 1) { + frame = requestAnimationFrame(step); + } + }; + frame = requestAnimationFrame(step); +} + +/** + * Called by every mounted backdrop. Refcounted like floating-ui's scroll lock, so stacked dialogs + * compose: the first open captures and tints, each further open re-derives from the deeper scrim, + * and only the last close restores. + * + * @param backdrop - the element whose computed background and transition timing drive both the + * target colour and how long it takes to get there. Reading the timing from CSS rather than + * duplicating a constant means the chrome automatically follows the sheet's longer fade on mobile. + */ +/** + * The colour the chrome should show right now: the captured base with every open dialog's scrim + * composited over it in order, so two stacked dialogs land on the same value their two backdrops + * do. Recomputed from scratch on every change rather than accumulated, which is what makes + * closing one of them exactly reversible. + */ +function resolveTint(): string | null { + if (!snapshot) { + return null; + } + let colour = snapshot.base; + for (const layer of layers) { + const next = composite(colour, layer.scrim); + if (!next) { + return null; + } + colour = toCss(next); + } + return colour; +} + +export function acquireBrowserChrome(backdrop: HTMLElement): () => void { + if (typeof document === 'undefined') { + return () => {}; + } + + // Reclaim a teardown that has not fired yet: the snapshot it would have torn down is the one + // about to be reused. + window.clearTimeout(teardown); + teardown = 0; + + if (!snapshot) { + const base = readBaseColor(); + const meta = document.createElement('meta'); + meta.name = 'theme-color'; + meta.content = base; + // PREPENDED, never mutating the app's own. The UA uses the first `theme-color` in tree order + // whose media matches, so inserting ahead of theirs overrides it without touching it — and + // removing ours restores their value with no bookkeeping. That also sidesteps frameworks that + // manage the tag themselves (Next's `viewport.themeColor`), which can revert a mutation on + // any re-render. + document.head.prepend(meta); + snapshot = { meta, bodyBackground: document.body.style.backgroundColor, base }; + } + + const styles = getComputedStyle(backdrop); + const layer: Layer = { scrim: styles.backgroundColor, styles }; + layers.push(layer); + + const target = resolveTint(); + if (target) { + animate(snapshot.meta.content, target, layerDuration(layer), layerEase(layer)); + } + + // Idempotent: callers release on `data-ending-style` and again at unmount, and a second call + // must not re-run the fade or the teardown. + let released = false; + return () => { + if (released) { + return; + } + released = true; + const index = layers.indexOf(layer); + if (index >= 0) { + layers.splice(index, 1); + } + if (!snapshot) { + return; + } + + // Dialogs still open: return to what THEY compose to. The old code returned early here, which + // left a nested dialog's deeper tint on the chrome after it closed. + if (layers.length > 0) { + const remaining = resolveTint(); + if (remaining) { + animate(snapshot.meta.content, remaining, layerDuration(layer), layerEase(layer)); + } + return; + } + + const closing = snapshot; + const { meta, bodyBackground, base } = closing; + // Fade back before tearing down, so closing reads as the reverse of opening rather than as a + // flash. `snapshot` is cleared only once the colour has landed. + const finish = () => { + teardown = 0; + // Identity check as well as the cancel above: a re-open replaces `snapshot`, and this + // closure must not remove a meta that now belongs to a dialog which is still open. + if (snapshot !== closing) { + return; + } + cancelAnimationFrame(frame); + meta.remove(); + document.body.style.backgroundColor = bodyBackground; + snapshot = null; + }; + const durationMs = layerDuration(layer); + if (durationMs <= 0) { + finish(); + return; + } + animate(meta.content, base, durationMs, layerEase(layer)); + teardown = window.setTimeout(finish, durationMs); + }; +} diff --git a/packages/ui/src/mosaic/components/dialog/dialog.test.tsx b/packages/ui/src/mosaic/components/dialog/dialog.test.tsx index 9b7ff5eb24e..de37cf661d8 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.test.tsx +++ b/packages/ui/src/mosaic/components/dialog/dialog.test.tsx @@ -494,6 +494,111 @@ describe('viewport scroll behaviour', () => { }); }); +describe('browser chrome sync', () => { + const themeColor = () => document.head.querySelector('meta[name="theme-color"]'); + + const addEmailTriggerShared = (props: MosaicComponentProps<'button'>) => ( + + ); + + afterEach(() => { + document.head.querySelectorAll('meta[name="theme-color"]').forEach(m => m.remove()); + document.body.style.backgroundColor = ''; + }); + + it('adds a theme-color meta while open and removes it on close', async () => { + const user = userEvent.setup(); + expect(themeColor()).toBeNull(); + + render( + + {({ close }) => ( + + )} + , + ); + // Acquired when the backdrop's transition arms — one frame after mount — not on the mount + // frame itself, where an inline `transition: none` would make the fade a snap. + await waitFor(() => expect(themeColor()).not.toBeNull()); + + await user.click(screen.getByRole('button', { name: 'Dismiss' })); + await waitFor(() => expect(themeColor()).toBeNull()); + }); + + it('prepends its meta so it wins over the app’s own, and leaves that one untouched', async () => { + const appMeta = document.createElement('meta'); + appMeta.name = 'theme-color'; + appMeta.content = 'rgb(10, 20, 30)'; + document.head.append(appMeta); + + render(Body); + await waitFor(() => expect(document.head.querySelectorAll('meta[name="theme-color"]')).toHaveLength(2)); + + const metas = document.head.querySelectorAll('meta[name="theme-color"]'); + // First in tree order is what the UA uses, so ours has to be first — and theirs unchanged. + expect(metas).toHaveLength(2); + expect(metas[0]).not.toBe(appMeta); + expect(appMeta.content).toBe('rgb(10, 20, 30)'); + }); + + it('opts out with syncBrowserChrome={false}', () => { + render( + + Body + , + ); + + expect(themeColor()).toBeNull(); + }); + + it('keeps the tint when a dialog re-opens before the previous teardown fires', async () => { + // Regression: closing schedules the meta's removal after the fade. React StrictMode's + // mount → cleanup → mount, or simply opening again quickly, used to let that deferred + // removal fire and strip the tint from a dialog that was still open. + const user = userEvent.setup(); + const { rerender } = render(Body); + await waitFor(() => expect(themeColor()).not.toBeNull()); + + rerender(Body); + rerender(Body); + + await new Promise(resolve => setTimeout(resolve, 250)); + expect(themeColor()).not.toBeNull(); + await user.keyboard('{Escape}'); + }); + + it('keeps one meta for stacked dialogs and removes it only with the last', async () => { + const user = userEvent.setup(); + render( + +
Outer
+ +
Inner
+
+
, + ); + + await user.click(screen.getByRole('button', { name: 'Add email' })); + expect(document.head.querySelectorAll('meta[name="theme-color"]')).toHaveLength(1); + + await user.keyboard('{Escape}'); + expect(themeColor()).not.toBeNull(); + }); +}); + describe('accessible name warning', () => { it('warns when the dialog has no accessible name', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); diff --git a/packages/ui/src/mosaic/components/dialog/dialog.tsx b/packages/ui/src/mosaic/components/dialog/dialog.tsx index 14d08dabf45..668450d4e08 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.tsx +++ b/packages/ui/src/mosaic/components/dialog/dialog.tsx @@ -10,6 +10,7 @@ import { mergeStyleProps, themeProps } from '../../props'; import { Button } from '../button'; import { Icon } from '../icon'; import { reset } from '../reset.styles'; +import { acquireBrowserChrome } from './browser-chrome'; import { backdropMotion, closeInsets, popupMotion, sizes, styles, viewportSizes } from './dialog.styles'; import { acquireKeyboardInset } from './keyboard-inset'; @@ -19,6 +20,17 @@ export type DialogSize = keyof typeof sizes; export interface DialogRootProps extends HeadlessDialogProps { /** Width, and for `panel` also height, of the dialog surface. @default 'prompt' */ size?: DialogSize; + /** + * Tint the mobile browser's own chrome — the address bar, and the canvas behind the overscroll + * gutter — to match the dialog's scrim, so an open dialog reads as one continuous surface. + * + * On by default. It ships no colour of its own (the target is derived from the backdrop + * composited over whatever the page already had), reverts exactly on close, and is inert + * wherever `theme-color` is ignored. Pass `false` if the app drives `theme-color` itself. + * + * @default true + */ + syncBrowserChrome?: boolean; } /** @@ -29,6 +41,56 @@ export interface DialogRootProps extends HeadlessDialogProps< */ const DialogSizeContext = React.createContext('prompt'); +/** Whether the dialog tints the mobile browser's chrome to match its scrim. See `browser-chrome.ts`. */ +const DialogChromeContext = React.createContext(true); + +/** + * Drives the browser-chrome tint off the backdrop element itself, so both the colour and the timing + * come from the CSS rather than from constants duplicated in JS. + * + * Keyed on the NODE via state rather than a ref: the effect has to run once the backdrop is in the + * DOM and its computed style is readable, and a ref gives no signal when that happens. + */ +function useBrowserChrome(node: HTMLElement | null, enabled: boolean) { + React.useEffect(() => { + if (!enabled || !node) { + return; + } + + // Driven by the backdrop's own transition attributes rather than by mount and unmount, so the + // colour runs on exactly the same clock as the scrim in both directions. + // + // Both attributes matter, for different reasons. `data-ending-style` because the headless + // layer keeps the backdrop mounted until its exit animation finishes, so releasing at unmount + // starts the revert only once the scrim has already gone. And `data-starting-style` because + // that frame carries an inline `transition: none` — acquiring there reads a duration of `0s` + // and the fade becomes a snap. Waiting for both to be absent is precisely waiting for the + // scrim's transition to arm. + // + // Two-way, because an exit can be interrupted: re-opening mid-exit clears the attribute on the + // same element, and the tint has to come back without waiting for a remount. + let handle: (() => void) | null = null; + const sync = () => { + const transitioning = node.hasAttribute('data-starting-style') || node.hasAttribute('data-ending-style'); + if (transitioning && handle) { + handle(); + handle = null; + } else if (!transitioning && !handle) { + handle = acquireBrowserChrome(node); + } + }; + + const observer = new MutationObserver(sync); + observer.observe(node, { attributes: true, attributeFilter: ['data-starting-style', 'data-ending-style'] }); + sync(); + + return () => { + observer.disconnect(); + handle?.(); + }; + }, [node, enabled]); +} + /** * The headless parts type their props (and the `render` callback's argument) against * the raw tag props, which carry the non-standard HTML `color` attribute typed @@ -73,10 +135,17 @@ export type DialogPopupProps = MosaicComponentProps<'div'> & { }; /** Owns the open state and the size both the backdrop and the popup read. */ -function Root({ size = 'prompt', children, ...rest }: DialogRootProps) { +function Root({ + size = 'prompt', + syncBrowserChrome = true, + children, + ...rest +}: DialogRootProps) { return ( - {...rest}>{children} + + {...rest}>{children} + ); } @@ -167,9 +236,22 @@ const Backdrop = React.forwardRef(function ref, ) { const size = React.useContext(DialogSizeContext); + const [node, setNode] = React.useState(null); + useBrowserChrome(node, React.useContext(DialogChromeContext)); + const mergedRef = React.useCallback( + (el: HTMLDivElement | null) => { + setNode(el); + if (typeof ref === 'function') { + ref(el); + } else if (ref) { + ref.current = el; + } + }, + [ref], + ); return ( void }) => ReactNode); /** Width, and for `panel` also height, of the dialog surface. @default 'prompt' */ size?: DialogSize; + /** Tint the mobile browser chrome to match the scrim. @default true */ + syncBrowserChrome?: boolean; } function DialogContent({ children }: { children: DialogProps['children'] }) { @@ -275,10 +359,21 @@ function DialogContent({ children }: { children: DialogProps['children'] }) { * keeps its focus management, scroll lock, and ARIA wiring. Drop to the compound parts * (`Dialog.Root` and friends) for layouts this wrapper does not cover. */ -export function Dialog({ trigger, children, size, open, defaultOpen, onOpenChange, modal, closedBy }: DialogProps) { +export function Dialog({ + trigger, + children, + size, + syncBrowserChrome, + open, + defaultOpen, + onOpenChange, + modal, + closedBy, +}: DialogProps) { return ( Date: Wed, 12 Aug 2026 14:33:07 -0600 Subject: [PATCH 2/2] chore(repo): add an empty changeset for the browser-chrome branch --- .changeset/dialog-browser-chrome.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .changeset/dialog-browser-chrome.md diff --git a/.changeset/dialog-browser-chrome.md b/.changeset/dialog-browser-chrome.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/dialog-browser-chrome.md @@ -0,0 +1,2 @@ +--- +---