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

@@ -96,12 +96,6 @@
</script> </script>
<div id="root"></div> <div id="root"></div>
<div id="portalContainer"></div> <div id="portalContainer"></div>
<style>
#portalContainer {
position: relative;
z-index: 10000;
}
</style>
<script type="module" src="./src/index.tsx"></script> <script type="module" src="./src/index.tsx"></script>
</body> </body>
</html> </html>

View File

@@ -3,38 +3,64 @@ import { color, config, toRem } from 'folds';
const MOBILE_BREAKPOINT = '480px'; const MOBILE_BREAKPOINT = '480px';
export const PortalLayer = style({ /** Above folds overlays (9999) and Settings modals. */
position: 'fixed', const UPDATES_DIALOG_Z = 10001;
inset: 0,
zIndex: config.zIndex.Max,
pointerEvents: 'auto',
});
/** Dialog + overlay padding must never exceed the viewport height. */
const DIALOG_MAX_HEIGHT = const DIALOG_MAX_HEIGHT =
'calc(85vh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))'; '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', boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
maxHeight: '100vh', maxHeight: '100vh',
maxWidth: '100vw', maxWidth: '100vw',
padding: 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)', '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', position: 'relative',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
width: '100%', width: '100%',
maxWidth: `min(${toRem(560)}, calc(100vw - ${config.space.S800}))`, maxWidth: toRem(560),
maxHeight: DIALOG_MAX_HEIGHT, maxHeight: DIALOG_MAX_HEIGHT,
overflow: 'hidden', overflow: 'hidden',
pointerEvents: 'auto',
borderRadius: config.radii.R400,
backgroundColor: color.Surface.Container, backgroundColor: color.Surface.Container,
color: color.Surface.OnContainer, color: color.Surface.OnContainer,
boxShadow: config.shadow.E400,
'@media': { '@media': {
[`(max-width: ${MOBILE_BREAKPOINT})`]: { [`(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({ export const BodyLoading = style({
padding: config.space.S800, padding: config.space.S600,
}); });
export const Markdown = style({ export const Markdown = style({
@@ -166,7 +192,7 @@ globalStyle(`${Markdown} code`, {
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: toRem(13), fontSize: toRem(13),
padding: `0 ${config.space.S100}`, padding: `0 ${config.space.S100}`,
borderRadius: config.radii.R200, borderRadius: config.radii.R300,
backgroundColor: color.SurfaceVariant.Container, backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer, color: color.SurfaceVariant.OnContainer,
}); });

View File

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

View File

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

View File

@@ -147,6 +147,27 @@ body.stationery-dark-theme {
background-color: #262626; background-color: #262626;
} }
/*
* Folds Overlay / PopOut portals mount here. The container must not steal taps;
* only its children should. Empty portal shells (e.g. closed overlays) must not
* block dialogs portaled elsewhere.
*/
#portalContainer {
position: fixed;
inset: 0;
z-index: 9998;
pointer-events: none;
overflow: hidden;
}
#portalContainer > * {
pointer-events: auto;
}
#portalContainer > *:empty {
pointer-events: none !important;
}
.twilight-theme #root { .twilight-theme #root {
background: linear-gradient(180deg, rgba(28, 26, 46, 0.5) 0%, rgba(36, 34, 61, 0.3) 100%); background: linear-gradient(180deg, rgba(28, 26, 46, 0.5) 0%, rgba(36, 34, 61, 0.3) 100%);
} }

View File

@@ -0,0 +1,101 @@
import path from 'path';
import { transformAsync } from '@babel/core';
import vanillaBabelPlugin from '@vanilla-extract/babel-plugin-debug-ids';
import typescriptSyntax from '@babel/plugin-syntax-typescript';
const CSS_TS_FILTER = /\.css\.(js|cjs|mjs|jsx|ts|tsx)(\?.*)?$/;
/** Slug for vanilla-extract class names (letters, digits, _, -). */
function sanitizeIdentifierPart(value) {
return String(value)
.replace(/\s/g, '_')
.replace(/[^a-zA-Z0-9_-]/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '');
}
function fileScopeSlug(filePath, projectRoot, packageName) {
const absolute = path.isAbsolute(filePath) ? filePath : path.join(projectRoot, filePath);
const normalized = absolute.replace(/\\/g, '/');
const srcRoot = path.join(projectRoot, 'src').replace(/\\/g, '/');
let rel;
if (normalized.startsWith(srcRoot)) {
rel = path.relative(path.join(projectRoot, 'src'), absolute);
} else if (normalized.includes('/src/')) {
rel = normalized.split('/src/').pop();
} else if (packageName) {
rel = path.join(packageName, path.basename(absolute));
} else {
rel = path.relative(projectRoot, absolute);
}
return sanitizeIdentifierPart(
String(rel)
.replace(/\\/g, '/')
.replace(/\.css\.(ts|tsx|js|cjs|mjs|jsx)$/i, '')
.replace(/\//g, '_')
);
}
function finalizeIdentifier(parts) {
let name = parts.filter(Boolean).join('_');
if (!name) {
name = 've_style';
}
if (/^[0-9]/.test(name)) {
name = `_${name}`;
}
if (!/^[A-Z_][0-9A-Z_-]+$/i.test(name)) {
name = `ve_${name}`;
}
return name;
}
/**
* Human-readable vanilla-extract class names without hash suffixes.
* Example: app_components_updates_dialog_UpdatesDialog_Root
*/
export function createReadableVanillaExtractIdentifiers(projectRoot) {
return function readableVanillaExtractIdentifier({ debugId, filePath, packageName, hash }) {
const scope = fileScopeSlug(filePath, projectRoot, packageName);
const exportName = debugId ? sanitizeIdentifierPart(debugId) : '';
if (exportName) {
return finalizeIdentifier([scope, exportName]);
}
// Unnamed styles (rare): keep a short disambiguator from the scoped hash.
const suffix = sanitizeIdentifierPart(String(hash).replace(/^_/, ''));
return finalizeIdentifier([scope, suffix]);
};
}
/**
* Injects export names into style() calls so readable identifiers can use them.
* Required when identifiers is a function — vanilla-extract only runs this for identOption === 'debug'.
*/
export function vanillaExtractDebugIdsPlugin() {
return {
name: 'vanilla-extract-debug-ids',
enforce: 'pre',
async transform(code, id) {
if (!CSS_TS_FILTER.test(id)) {
return null;
}
const result = await transformAsync(code, {
filename: id,
plugins: [vanillaBabelPlugin, typescriptSyntax],
configFile: false,
babelrc: false,
});
if (!result?.code) {
return null;
}
return { code: result.code, map: result.map };
},
};
}

View File

@@ -11,6 +11,10 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import buildConfig from './build.config'; import buildConfig from './build.config';
import { liveTsxPlugin, readDefaultLiveSource } from './playground-liveTsxPlugin'; import { liveTsxPlugin, readDefaultLiveSource } from './playground-liveTsxPlugin';
import {
createReadableVanillaExtractIdentifiers,
vanillaExtractDebugIdsPlugin,
} from './vanillaExtractIdentifiers.js';
const projectRoot = path.resolve(); const projectRoot = path.resolve();
@@ -252,7 +256,7 @@ function corsProxyMiddleware() {
}; };
} }
export default defineConfig({ export default defineConfig(() => ({
appType: 'spa', appType: 'spa',
publicDir: false, publicDir: false,
base: buildConfig.base, base: buildConfig.base,
@@ -304,7 +308,15 @@ export default defineConfig({
promiseImportName: (i) => `__tla_${i}`, promiseImportName: (i) => `__tla_${i}`,
}), }),
viteStaticCopy(copyFiles), viteStaticCopy(copyFiles),
vanillaExtractPlugin(), ...(process.env.VITE_VE_IDENTIFIERS === 'short' ? [] : [vanillaExtractDebugIdsPlugin()]),
vanillaExtractPlugin({
unstable_pluginFilter: ({ name }) =>
name === 'vite-tsconfig-paths' || name === 'vanilla-extract-debug-ids',
identifiers:
process.env.VITE_VE_IDENTIFIERS === 'short'
? 'short'
: createReadableVanillaExtractIdentifiers(projectRoot),
}),
wasm(), wasm(),
react(), react(),
VitePWA({ VitePWA({
@@ -359,4 +371,4 @@ export default defineConfig({
plugins: [inject({ Buffer: ['buffer', 'Buffer'] })], plugins: [inject({ Buffer: ['buffer', 'Buffer'] })],
}, },
}, },
}); }));