Fix What's New dialog layout and improve dev styling/debugging.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s

Portal the updates dialog to document.body with stable data attributes,
fix max-width (invalid S800 token), bundle update images for Capacitor,
and add readable vanilla-extract class names plus portalContainer tap fixes.
This commit is contained in:
2026-08-23 16:54:59 +10:00
parent 7d866404b3
commit 6cb1e14632
7 changed files with 224 additions and 57 deletions

View File

@@ -3,38 +3,64 @@ import { color, config, toRem } from 'folds';
const MOBILE_BREAKPOINT = '480px';
export const PortalLayer = style({
position: 'fixed',
inset: 0,
zIndex: config.zIndex.Max,
pointerEvents: 'auto',
});
/** Above folds overlays (9999) and Settings modals. */
const UPDATES_DIALOG_Z = 10001;
/** Dialog + overlay padding must never exceed the viewport height. */
const DIALOG_MAX_HEIGHT =
'calc(85vh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))';
export const OverlayFrame = style({
/** [data-updates-dialog] — full-screen host portaled to document.body */
export const Root = style({
position: 'fixed',
inset: 0,
zIndex: UPDATES_DIALOG_Z,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'auto',
});
/** [data-updates-dialog-backdrop] */
export const Backdrop = style({
position: 'absolute',
inset: 0,
zIndex: 0,
backgroundColor: 'rgba(0, 0, 0, 0.55)',
});
/** [data-updates-dialog-frame] */
export const Frame = style({
position: 'relative',
zIndex: 1,
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
maxHeight: '100vh',
maxWidth: '100vw',
padding:
'env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px)',
pointerEvents: 'none',
});
export const DialogShell = style({
/** [data-updates-dialog-panel] */
export const Panel = style({
position: 'relative',
display: 'flex',
flexDirection: 'column',
width: '100%',
maxWidth: `min(${toRem(560)}, calc(100vw - ${config.space.S800}))`,
maxWidth: toRem(560),
maxHeight: DIALOG_MAX_HEIGHT,
overflow: 'hidden',
pointerEvents: 'auto',
borderRadius: config.radii.R400,
backgroundColor: color.Surface.Container,
color: color.Surface.OnContainer,
boxShadow: config.shadow.E400,
'@media': {
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
maxWidth: `calc(100vw - ${config.space.S400})`,
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
},
},
});
@@ -103,7 +129,7 @@ export const BodyContent = style({
});
export const BodyLoading = style({
padding: config.space.S800,
padding: config.space.S600,
});
export const Markdown = style({
@@ -166,7 +192,7 @@ globalStyle(`${Markdown} code`, {
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: toRem(13),
padding: `0 ${config.space.S100}`,
borderRadius: config.radii.R200,
borderRadius: config.radii.R300,
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
});

View File

@@ -1,19 +1,7 @@
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
Dialog,
IconButton,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll,
Spinner,
Text,
usePopOutContainer,
} from 'folds';
import { Box, Button, IconButton, Scroll, Spinner, Text } from 'folds';
import { Icon, Icons } from '../icons';
import { stopPropagation } from '../../utils/keyboard';
import type { ParsedUpdateDoc, UpdateManifest } from '../../data/updateNotes';
@@ -37,7 +25,6 @@ export function UpdatesDialog({
onClose,
}: UpdatesDialogProps) {
const [showOlderList, setShowOlderList] = useState(false);
const popOutContainer = usePopOutContainer();
const displayVersion = activeDoc?.version;
const displayTitle = activeDoc?.title ?? (displayVersion ? `Version ${displayVersion}` : 'Release notes');
@@ -49,29 +36,35 @@ export function UpdatesDialog({
setShowOlderList(false);
};
const portalTarget =
popOutContainer ??
document.getElementById('portalContainer') ??
document.body;
return createPortal(
<div className={css.PortalLayer}>
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter className={css.OverlayFrame}>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: false,
escapeDeactivates: stopPropagation,
}}
<div
data-updates-dialog=""
className={css.Root}
data-disable-swipe-back="true"
data-disable-swipe-reply="true"
>
<div data-updates-dialog-backdrop="" className={css.Backdrop} aria-hidden="true" />
<div data-updates-dialog-frame="" className={css.Frame}>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: false,
escapeDeactivates: stopPropagation,
}}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="updates-dialog-title"
data-updates-dialog-panel=""
className={css.Panel}
>
<Dialog variant="Surface" className={css.DialogShell}>
<Box className={css.Hero}>
<Box className={css.HeroRow}>
<img className={css.HeroLogo} src={PaarrotSVG} alt="" draggable={false} />
<Box className={css.HeroTitleWrap}>
<Text size="L400" priority="300">What&apos;s new</Text>
<Text size="H4" truncate>
<Text id="updates-dialog-title" size="H4" truncate>
{displayVersion ? `Paarrot ${displayVersion}` : 'Paarrot updates'}
</Text>
</Box>
@@ -181,11 +174,10 @@ export function UpdatesDialog({
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Dialog>
</div>
</FocusTrap>
</OverlayCenter>
</Overlay>
</div>
</div>,
portalTarget
document.body
);
}

View File

@@ -7,6 +7,11 @@ const bundledMarkdownByFile = import.meta.glob('../../../public/update/*.md', {
eager: true,
}) as Record<string, string>;
const bundledImageByFile = import.meta.glob('../../../public/update/images/*', {
import: 'default',
eager: true,
}) as Record<string, string>;
function getUpdateBaseUrl(): string {
const basePath = trimTrailingSlash(import.meta.env.BASE_URL || './');
const relative =
@@ -88,12 +93,28 @@ function loadBundledDocument(file: string): ParsedUpdateDoc | null {
return parseUpdateMarkdown(safeFile, entry[1]);
}
function resolveBundledImageUrl(normalizedPath: string): string | undefined {
const needle = normalizedPath.replace(/^\/+/, '');
if (!needle) return undefined;
const entry = Object.entries(bundledImageByFile).find(([path]) => {
const normalized = path.replace(/\\/g, '/');
return normalized.endsWith(`/${needle}`) || normalized.endsWith(`/${needle.split('/').pop() ?? ''}`);
});
return entry?.[1];
}
export function resolveUpdateAssetUrl(src: string | undefined): string | undefined {
if (!src) return undefined;
if (/^(https?:|data:|blob:|capacitor:)/i.test(src)) return src;
const normalized = src.replace(/^\.\//, '').replace(/^\/+/, '');
const bundled = resolveBundledImageUrl(normalized);
if (bundled) return bundled;
if (src.startsWith('/')) return src;
const normalized = src.replace(/^\.\//, '');
return new URL(normalized, `${getUpdateBaseUrl()}/`).href;
}