From 6cb1e146325ef96d4bc715ad9e8d3702a4bb8607 Mon Sep 17 00:00:00 2001 From: litruv Date: Sun, 23 Aug 2026 16:54:59 +1000 Subject: [PATCH] Fix What's New dialog layout and improve dev styling/debugging. 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. --- index.html | 6 -- .../updates-dialog/UpdatesDialog.css.ts | 52 ++++++--- .../updates-dialog/UpdatesDialog.tsx | 60 +++++------ src/app/data/updateNotes.ts | 23 +++- src/index.css | 21 ++++ vanillaExtractIdentifiers.js | 101 ++++++++++++++++++ vite.config.js | 18 +++- 7 files changed, 224 insertions(+), 57 deletions(-) create mode 100644 vanillaExtractIdentifiers.js diff --git a/index.html b/index.html index 0f482d2..3d1d48b 100644 --- a/index.html +++ b/index.html @@ -96,12 +96,6 @@
- diff --git a/src/app/components/updates-dialog/UpdatesDialog.css.ts b/src/app/components/updates-dialog/UpdatesDialog.css.ts index c313196..c0e4734 100644 --- a/src/app/components/updates-dialog/UpdatesDialog.css.ts +++ b/src/app/components/updates-dialog/UpdatesDialog.css.ts @@ -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, }); diff --git a/src/app/components/updates-dialog/UpdatesDialog.tsx b/src/app/components/updates-dialog/UpdatesDialog.tsx index 163d49f..7dce600 100644 --- a/src/app/components/updates-dialog/UpdatesDialog.tsx +++ b/src/app/components/updates-dialog/UpdatesDialog.tsx @@ -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( -
- }> - - + , - portalTarget + document.body ); } diff --git a/src/app/data/updateNotes.ts b/src/app/data/updateNotes.ts index 99b8f1e..7a0a981 100644 --- a/src/app/data/updateNotes.ts +++ b/src/app/data/updateNotes.ts @@ -7,6 +7,11 @@ const bundledMarkdownByFile = import.meta.glob('../../../public/update/*.md', { eager: true, }) as Record; +const bundledImageByFile = import.meta.glob('../../../public/update/images/*', { + import: 'default', + eager: true, +}) as Record; + 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; } diff --git a/src/index.css b/src/index.css index 0174bd4..ac87ab0 100644 --- a/src/index.css +++ b/src/index.css @@ -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%); } diff --git a/vanillaExtractIdentifiers.js b/vanillaExtractIdentifiers.js new file mode 100644 index 0000000..dc8c2fe --- /dev/null +++ b/vanillaExtractIdentifiers.js @@ -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 }; + }, + }; +} diff --git a/vite.config.js b/vite.config.js index e70c74f..3baaf6b 100644 --- a/vite.config.js +++ b/vite.config.js @@ -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(); @@ -252,7 +256,7 @@ function corsProxyMiddleware() { }; } -export default defineConfig({ +export default defineConfig(() => ({ appType: 'spa', publicDir: false, base: buildConfig.base, @@ -304,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({ @@ -359,4 +371,4 @@ export default defineConfig({ plugins: [inject({ Buffer: ['buffer', 'Buffer'] })], }, }, -}); +}));