Compare commits

3 Commits

Author SHA1 Message Date
6cb1e14632 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.
2026-08-23 16:54:59 +10:00
7d866404b3 Fix Android updates dialog, mobile About page, and user color crash guard.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Use stripBase for update static copy paths, show Settings detail on mobile
when nav is hidden, pause Settings focus trap during release notes, and guard
extractMemberColorPreference when room is not a Matrix Room instance.
2026-08-23 16:18:35 +10:00
c264de53b5 Fix View updates on Android: bundled notes, portal stack, tap handling.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Bundle update markdown at build time for Capacitor, portal into
portalContainer above Settings, defer open to avoid FocusTrap closing
on the same tap, and disable click-outside dismiss.
2026-08-23 08:38:23 +10:00
11 changed files with 285 additions and 64 deletions

View File

@@ -1,7 +1,9 @@
import React, { ReactNode, useCallback, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { useAtomValue } from 'jotai';
import { Modal, Overlay, OverlayBackdrop, OverlayCenter, PopOutContainerProvider } from 'folds';
import { stopPropagation } from '../utils/keyboard';
import { releaseNotesDialogAtom } from '../state/releaseNotes';
type Modal500Props = {
requestClose: () => void;
@@ -10,11 +12,13 @@ type Modal500Props = {
export function Modal500({ requestClose, children }: Modal500Props) {
const [modalEl, setModalEl] = useState<HTMLDivElement | null>(null);
const modalRef = useCallback((el: HTMLDivElement | null) => setModalEl(el), []);
const releaseNotesOpen = useAtomValue(releaseNotesDialogAtom).open;
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
active={!releaseNotesOpen}
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,

View File

@@ -16,6 +16,7 @@ type PageRootProps = {
export function PageRoot({ nav, children }: PageRootProps) {
const screenSize = useScreenSizeContext();
const showCompactMaster = useShowCompactMasterView();
const showDetail = !showCompactMaster || nav == null;
return (
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
@@ -23,7 +24,7 @@ export function PageRoot({ nav, children }: PageRootProps) {
{screenSize !== ScreenSize.Mobile && (
<Line variant="Background" size="300" direction="Vertical" />
)}
{!showCompactMaster && children}
{showDetail && children}
</Box>
);
}

View File

@@ -3,37 +3,64 @@ import { color, config, toRem } from 'folds';
const MOBILE_BREAKPOINT = '480px';
export const PortalLayer = style({
position: 'fixed',
inset: 0,
zIndex: config.zIndex.Max,
});
/** 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})`,
},
},
});
@@ -102,7 +129,7 @@ export const BodyContent = style({
});
export const BodyLoading = style({
padding: config.space.S800,
padding: config.space.S600,
});
export const Markdown = style({
@@ -165,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,
config,
Dialog,
IconButton,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll,
Spinner,
Text,
} 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';
@@ -49,24 +37,34 @@ export function UpdatesDialog({
};
return createPortal(
<div className={css.PortalLayer}>
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter className={css.OverlayFrame}>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: onClose,
clickOutsideDeactivates: true,
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>
@@ -176,10 +174,9 @@ export function UpdatesDialog({
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Dialog>
</div>
</FocusTrap>
</OverlayCenter>
</Overlay>
</div>
</div>,
document.body
);

View File

@@ -26,7 +26,10 @@ type UpdaterInfo = {
export function useOpenReleaseNotesDialog(): () => void {
const setDialogState = useSetAtom(releaseNotesDialogAtom);
return useCallback(() => {
setDialogState({ open: true, manual: true });
// Defer so the opening tap does not trip FocusTrap click-outside on Android.
window.requestAnimationFrame(() => {
setDialogState({ open: true, manual: true });
});
}, [setDialogState]);
}

View File

@@ -1,5 +1,17 @@
import bundledManifest from '../../../public/update/manifest.json';
import { trimTrailingSlash } from '../utils/common';
const bundledMarkdownByFile = import.meta.glob('../../../public/update/*.md', {
query: '?raw',
import: 'default',
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 =
@@ -64,27 +76,66 @@ function parseUpdateMarkdown(file: string, source: string): ParsedUpdateDoc {
};
}
function loadBundledManifest(): UpdateManifest {
const manifest = bundledManifest as UpdateManifest;
return {
current: manifest.current,
older: (manifest.older ?? []).filter((entry) => entry?.file),
};
}
function loadBundledDocument(file: string): ParsedUpdateDoc | null {
const safeFile = file.replace(/^\/+/, '');
const entry = Object.entries(bundledMarkdownByFile).find(([path]) =>
path.endsWith(`/${safeFile}`)
);
if (!entry) return 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:)/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;
const normalized = src.replace(/^\.\//, '');
return new URL(normalized, `${getUpdateBaseUrl()}/`).href;
}
export async function loadUpdateManifest(): Promise<UpdateManifest | null> {
try {
const response = await fetch(getUpdateFileUrl('manifest.json'), { cache: 'no-cache' });
if (!response.ok) return null;
if (response.ok) {
const data = (await response.json()) as UpdateManifest;
if (data?.current && Array.isArray(data.older)) {
return {
current: data.current,
older: data.older.filter((entry) => entry?.file),
};
}
}
} catch {
// fall through to bundled copy (Capacitor / offline)
}
const data = (await response.json()) as UpdateManifest;
if (!data?.current || !Array.isArray(data.older)) return null;
return {
current: data.current,
older: data.older.filter((entry) => entry?.file),
};
try {
return loadBundledManifest();
} catch {
return null;
}
@@ -96,13 +147,15 @@ export async function loadUpdateDocument(file: string): Promise<ParsedUpdateDoc
try {
const response = await fetch(getUpdateFileUrl(safeFile), { cache: 'no-cache' });
if (!response.ok) return null;
const source = await response.text();
return parseUpdateMarkdown(safeFile, source);
if (response.ok) {
const source = await response.text();
return parseUpdateMarkdown(safeFile, source);
}
} catch {
return null;
// fall through to bundled copy
}
return loadBundledDocument(safeFile);
}
export async function loadCurrentUpdateDocument(): Promise<ParsedUpdateDoc | null> {

View File

@@ -21,6 +21,9 @@ export function About({ requestClose }: AboutProps) {
const [version, setVersion] = useState<string>('');
const [protocolStatus, setProtocolStatus] = useState<string>('Checking desktop protocol integration...');
const [protocolBusy, setProtocolBusy] = useState<boolean>(false);
const [updatePreview, setUpdatePreview] = useState<{ title: string; description: string } | null>(
null
);
const formatProtocolStatus = useCallback((data: {
scheme: string;
@@ -101,8 +104,6 @@ export function About({ requestClose }: AboutProps) {
getCurrentUpdatePreview().then(setUpdatePreview).catch(() => setUpdatePreview(null));
}, [refreshProtocolStatus]);
const [updatePreview, setUpdatePreview] = useState<{ title: string; description: string } | null>(null);
return (
<Page>
<PageHeader outlined={false}>

View File

@@ -152,7 +152,7 @@ export async function loadColorPreference(
}
export function extractMemberColorPreference(room: Room | undefined, userId: string): ColorPreference | undefined {
if (!room) return undefined;
if (!room || typeof (room as Room).getMember !== 'function' || !userId) return undefined;
const member = room.getMember(userId);
const content = member?.events.member?.getContent();
if (!content) return undefined;

View File

@@ -147,6 +147,27 @@ body.stationery-dark-theme {
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 {
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 buildConfig from './build.config';
import { liveTsxPlugin, readDefaultLiveSource } from './playground-liveTsxPlugin';
import {
createReadableVanillaExtractIdentifiers,
vanillaExtractDebugIdsPlugin,
} from './vanillaExtractIdentifiers.js';
const projectRoot = path.resolve();
@@ -56,7 +60,8 @@ const copyFiles = {
{
src: 'public/update/**/*',
dest: 'update',
rename: (_name, _ext, fullPath) => fullPath.replace(/^public[/\\]update[/\\]/, ''),
// stripBase removes public/update from the matched path; rename alone only changes the filename.
rename: { stripBase: 2 },
},
],
};
@@ -251,7 +256,7 @@ function corsProxyMiddleware() {
};
}
export default defineConfig({
export default defineConfig(() => ({
appType: 'spa',
publicDir: false,
base: buildConfig.base,
@@ -303,7 +308,15 @@ export default defineConfig({
promiseImportName: (i) => `__tla_${i}`,
}),
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(),
react(),
VitePWA({
@@ -358,4 +371,4 @@ export default defineConfig({
plugins: [inject({ Buffer: ['buffer', 'Buffer'] })],
},
},
});
}));