From 97d19902892d594c57a6826b58936ec7e07d80c5 Mon Sep 17 00:00:00 2001 From: Michael McQuade Date: Sat, 24 Oct 2026 23:24:56 -0500 Subject: [PATCH] perf(ui): make app frame navigation styles optional --- packages/ui/README.md | 17 ++ packages/ui/package.json | 10 +- packages/ui/scripts/verify-pack.mjs | 58 ++++ .../ui/src/app-frame/AppFrame.stories.tsx | 61 ++++ packages/ui/src/app-frame/app-frame.css | 283 +----------------- packages/ui/src/app-frame/layout.css | 222 ++++++++++++++ packages/ui/src/app-frame/navigation.css | 69 +++++ 7 files changed, 436 insertions(+), 284 deletions(-) create mode 100644 packages/ui/src/app-frame/layout.css create mode 100644 packages/ui/src/app-frame/navigation.css diff --git a/packages/ui/README.md b/packages/ui/README.md index 545d80e4..638e17a1 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -1326,3 +1326,20 @@ link; put page content inside it without nesting a second `
`. Frame styles use the `pw-ui` cascade layer. Put broad host element defaults (such as anchor colors and scrollbar styling) in a lower-priority layer declared before the package styles, so they do not replace navigation states. + +Applications that supply their own navigation items can load only the layout: + +```tsx +import { AppFrame, AppFrameToggle } from '@parallelworks/ui/app-frame' +import '@parallelworks/ui/app-frame/layout.css' +``` + +This includes the sidebar, scrolling regions, resize handle, mobile drawer, +toggle and close buttons, focus indicators, and skip link. Style custom navigation +items in the host, including their selected, hover, rail, and mobile states. + +When using `AppFrameNavItem`, also import +`@parallelworks/ui/app-frame/navigation.css`. The existing +`@parallelworks/ui/app-frame.css` import includes both stylesheets and continues +to work without changes. Use either the combined import or the two separate +imports; there is no need to load both forms. diff --git a/packages/ui/package.json b/packages/ui/package.json index eef91658..a7e3478a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -36,7 +36,9 @@ "./ai/mock": "./src/ai/adapter/mock/index.ts", "./ai.css": "./src/ai/ai.css", "./app-frame": "./src/app-frame/index.tsx", - "./app-frame.css": "./src/app-frame/app-frame.css" + "./app-frame.css": "./src/app-frame/app-frame.css", + "./app-frame/layout.css": "./src/app-frame/layout.css", + "./app-frame/navigation.css": "./src/app-frame/navigation.css" }, "publishConfig": { "registry": "https://registry.npmjs.org", @@ -109,7 +111,9 @@ "types": "./dist/app-frame/index.d.ts", "import": "./dist/app-frame/index.js" }, - "./app-frame.css": "./dist/app-frame/app-frame.css" + "./app-frame.css": "./dist/app-frame/app-frame.css", + "./app-frame/layout.css": "./dist/app-frame/layout.css", + "./app-frame/navigation.css": "./dist/app-frame/navigation.css" } }, "files": [ @@ -118,7 +122,7 @@ "scripts": { "build": "vite build && pnpm build:types && pnpm build:css", "build:types": "tsc -p tsconfig.build.json && node scripts/declaration-extensions.mjs", - "build:css": "mkdir -p dist/styles dist/logviewer dist/ai && cp src/styles/theme.css src/styles/base.css src/styles/fonts.css src/styles/*.woff2 dist/styles/ && tailwindcss -i ./src/styles.css -o ./dist/styles.css --minify && cp src/logviewer/logviewer.css dist/logviewer/ && cp src/ai/ai.css dist/ai/ && mkdir -p dist/app-frame && cp src/app-frame/app-frame.css dist/app-frame/", + "build:css": "mkdir -p dist/styles dist/logviewer dist/ai && cp src/styles/theme.css src/styles/base.css src/styles/fonts.css src/styles/*.woff2 dist/styles/ && tailwindcss -i ./src/styles.css -o ./dist/styles.css --minify && cp src/logviewer/logviewer.css dist/logviewer/ && cp src/ai/ai.css dist/ai/ && mkdir -p dist/app-frame && cp src/app-frame/*.css dist/app-frame/ && cat src/app-frame/layout.css src/app-frame/navigation.css > dist/app-frame/app-frame.css", "clean": "rm -rf dist", "test": "vitest run", "lint:package": "publint --strict", diff --git a/packages/ui/scripts/verify-pack.mjs b/packages/ui/scripts/verify-pack.mjs index 096ac4f6..2f52d4f6 100644 --- a/packages/ui/scripts/verify-pack.mjs +++ b/packages/ui/scripts/verify-pack.mjs @@ -17,6 +17,7 @@ import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { build } from 'vite' const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const run = (cmd, args, cwd) => @@ -35,6 +36,8 @@ const CSS_ENTRYPOINTS = [ 'logviewer.css', 'ai.css', 'app-frame.css', + 'app-frame/layout.css', + 'app-frame/navigation.css', ] const uiPkg = JSON.parse(readFileSync(path.join(pkgDir, 'package.json'), 'utf8')) @@ -119,6 +122,61 @@ try { console.log('• installing the tarball into a fixture (npm, as a consumer would)') run('npm', ['install', '--no-audit', '--no-fund', '--loglevel', 'error'], fixture) + console.log('• bundling the frame styles from the installed package') + // A direct stylesheet link must remain one request, without an import waterfall. + const combinedCss = readFileSync( + path.join(fixture, 'node_modules/@parallelworks/ui/dist/app-frame/app-frame.css'), + 'utf8', + ) + if (combinedCss.includes('@import')) { + throw new Error('published app-frame.css must inline its layout and navigation styles') + } + const frameCss = {} + for (const [name, imports] of Object.entries({ + combined: ['app-frame.css'], + split: ['app-frame/layout.css', 'app-frame/navigation.css'], + layout: ['app-frame/layout.css'], + })) { + const entry = path.join(fixture, `${name}.css`) + writeFileSync( + entry, + imports.map((subpath) => `@import '@parallelworks/ui/${subpath}';`).join('\n'), + ) + const result = await build({ + configFile: false, + root: fixture, + logLevel: 'error', + build: { write: false, rolldownOptions: { input: entry } }, + }) + const sheets = result.output.filter( + (file) => file.type === 'asset' && file.fileName.endsWith('.css'), + ) + if (sheets.length !== 1) throw new Error(`${name} frame import did not emit one stylesheet`) + frameCss[name] = String(sheets[0].source) + if (!frameCss[name].includes('@layer pw-ui{') || frameCss[name].includes('@import')) { + throw new Error(`${name} frame stylesheet lost its layer or has unresolved imports`) + } + } + if (frameCss.combined !== frameCss.split) { + throw new Error('combined and separate frame imports produce different styles') + } + if (/\.pw-frame-nav-(item|icon|label|badge)\b/.test(frameCss.layout)) { + throw new Error('layout-only frame import includes optional navigation item styles') + } + if (!frameCss.combined.includes('.pw-frame-nav-item')) { + throw new Error('combined frame import is missing navigation item styles') + } + for (const selector of [ + '.pw-frame-sidebar', + '.pw-frame-drawer', + '.pw-frame-toggle', + '.pw-frame-skip', + ]) { + if (!frameCss.layout.includes(selector)) { + throw new Error(`layout-only frame import is missing ${selector}`) + } + } + console.log('• asserting the standalone stylesheet is layered') const css = readFileSync( path.join(fixture, 'node_modules/@parallelworks/ui/dist/styles.css'), diff --git a/packages/ui/src/app-frame/AppFrame.stories.tsx b/packages/ui/src/app-frame/AppFrame.stories.tsx index fb46c18c..f923f868 100644 --- a/packages/ui/src/app-frame/AppFrame.stories.tsx +++ b/packages/ui/src/app-frame/AppFrame.stories.tsx @@ -280,6 +280,67 @@ export const Banner: Story = { }, } export const Dark: Story = { globals: { mode: 'dark' } } +function CustomNavigationDemo(args: AppFrameProps) { + const [active, setActive] = useState('Projects') + return ( + {expanded ? 'Workspace' : 'W'}} + navigation={({ expanded, isMobile }) => ( + + )} + footer={({ isMobile }) => !isMobile && } + header={
} + > +
+

{active}

+

The host styles these navigation buttons. The frame owns layout and its controls.

+
+ + ) +} +export const CustomNavigation: Story = { + render: CustomNavigationDemo, + parameters: { + docs: { + description: { + story: + 'Custom navigation can use @parallelworks/ui/app-frame/layout.css alone. AppFrameNavItem requires navigation.css as well, or the combined app-frame.css import.', + }, + }, + }, +} export const Mobile: Story = { args: { mobileBreakpoint: 10000, style: { width: 390, maxWidth: '100%', height: 700 } }, } diff --git a/packages/ui/src/app-frame/app-frame.css b/packages/ui/src/app-frame/app-frame.css index 02c857f5..46c67a33 100644 --- a/packages/ui/src/app-frame/app-frame.css +++ b/packages/ui/src/app-frame/app-frame.css @@ -1,281 +1,2 @@ -@layer pw-ui { - /* The frame uses the same sidebar tokens as the theme generator. */ - .pw-app-frame { - display: flex; - flex-direction: column; - height: 100dvh; - min-height: 0; - isolation: isolate; - background: var(--theme-sidebar-bg, var(--theme-muted-panel-bg)); - color: var(--theme-app); - } - .pw-frame-body { - display: flex; - flex: 1; - min-height: 0; - position: relative; - overflow: hidden; - } - .pw-frame-sidebar, - .pw-frame-drawer { - display: flex; - flex-direction: column; - flex-shrink: 0; - min-height: 0; - background: var(--theme-sidebar-bg, var(--theme-muted-panel-bg)); - color: var(--theme-sidebar-text, var(--theme-app)); - } - .pw-frame-sidebar { - position: relative; - transition: width 200ms cubic-bezier(0.25, 0.1, 0.25, 1); - } - .pw-frame-sidebar[data-resizing] { - transition: none; - user-select: none; - } - .pw-frame-brand { - padding: 12px 12px 4px; - flex-shrink: 0; - min-width: 0; - overflow: hidden; - } - .pw-frame-links { - flex: 1; - overflow-x: hidden; - overflow-y: auto; - padding: 12px; - min-width: 0; - min-height: 0; - scrollbar-width: none; - } - .pw-frame-links::-webkit-scrollbar { - display: none; - } - .pw-frame-links nav { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 4px; - } - .pw-frame-footer { - flex-shrink: 0; - padding: 8px 12px; - } - .pw-frame-nav-item { - display: flex; - align-items: center; - gap: 8px; - min-height: 36px; - min-width: 0; - width: 100%; - border: 0; - border-radius: 8px; - padding: 8px; - background: transparent; - color: inherit; - font: inherit; - font-size: 14px; - font-weight: 500; - line-height: 20px; - cursor: pointer; - text-decoration: none; - text-align: start; - transition: background-color 150ms ease-out; - } - .pw-frame-nav-item:hover, - .pw-frame-toggle:hover { - background: var(--theme-sidebar-hover, var(--theme-hover)); - } - .pw-frame-nav-item[aria-current] { - background: var(--theme-sidebar-active-bg, var(--theme-accent-subtle)); - color: var(--theme-sidebar-active-text, var(--theme-accent)); - } - .pw-frame-nav-item:is(:disabled, [aria-disabled]) { - opacity: 0.5; - cursor: not-allowed; - } - .pw-frame-nav-icon { - display: flex; - flex: 0 0 18px; - align-items: center; - justify-content: center; - } - .pw-frame-nav-icon svg { - width: 18px; - height: 18px; - } - .pw-frame-nav-label { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - .pw-frame-nav-badge { - flex-shrink: 0; - font-size: 12px; - font-variant-numeric: tabular-nums; - } - [data-sidebar-mode="rail"] .pw-frame-nav-item { - padding-inline: 0; - justify-content: center; - height: 40px; - } - .pw-frame-toggle { - display: inline-flex; - align-items: center; - justify-content: center; - width: 36px; - height: 36px; - border: 0; - border-radius: 8px; - padding: 0; - color: inherit; - background: transparent; - cursor: pointer; - flex-shrink: 0; - } - .pw-frame-toggle svg { - width: 18px; - height: 18px; - } - .pw-frame-sidebar :focus-visible, - .pw-frame-drawer :focus-visible { - outline: 2px solid currentColor; - outline-offset: 2px; - } - .pw-frame-resize { - position: absolute; - z-index: 1; - inset-block: 0; - inset-inline-end: -4px; - width: 8px; - cursor: col-resize; - touch-action: none; - } - .pw-frame-resize::after { - content: ""; - position: absolute; - inset-block: 8px; - inset-inline-start: 3px; - width: 2px; - border-radius: 2px; - } - .pw-frame-resize:hover::after, - .pw-frame-resize:focus-visible::after { - background: var(--theme-sidebar-text, var(--theme-accent)); - opacity: 0.5; - } - .pw-frame-surface-wrap { - flex: 1; - display: flex; - flex-direction: column; - min-width: 0; - min-height: 0; - padding: 8px; - padding-inline-start: 0; - } - .pw-frame-surface { - flex: 1; - display: flex; - flex-direction: column; - min-width: 0; - min-height: 0; - overflow: hidden; - isolation: isolate; - border: 1px solid var(--theme-border); - border-radius: 12px; - background: var(--theme-app-bg); - background-clip: padding-box; - } - .pw-frame-header, - .pw-frame-banner { - flex-shrink: 0; - } - .pw-frame-main { - flex: 1; - min-width: 0; - min-height: 0; - overflow: auto; - } - .pw-app-frame[data-full-bleed] .pw-frame-surface-wrap { - padding: 0; - } - .pw-app-frame[data-full-bleed] .pw-frame-surface { - border: 0; - border-radius: 0; - } - .pw-app-frame[data-mobile] .pw-frame-surface-wrap { - padding: 8px; - } - .pw-app-frame[data-mobile][data-full-bleed] .pw-frame-surface-wrap { - padding: 0; - } - .pw-frame-backdrop { - position: absolute; - inset: 0; - z-index: var(--layer-drawer, 1100); - background: var(--theme-backdrop, rgb(0 0 0 / 0.35)); - transition: opacity 200ms cubic-bezier(0.25, 0.1, 0.25, 1); - } - .pw-frame-backdrop[data-starting-style], - .pw-frame-backdrop[data-ending-style] { - opacity: 0; - } - .pw-frame-drawer { - position: absolute; - inset-block: 8px; - inset-inline-start: 8px; - max-width: calc(100% - 48px); - z-index: var(--layer-drawer, 1100); - border-radius: 12px; - box-shadow: 0 12px 32px rgb(0 0 0 / 0.2); - outline: none; - transform: translateX(0); - transition: transform 200ms cubic-bezier(0.25, 0.1, 0.25, 1); - } - .pw-frame-drawer[data-starting-style], - .pw-frame-drawer[data-ending-style] { - transform: translateX(calc(-100% - 16px)); - } - .pw-frame-drawer:dir(rtl)[data-starting-style], - .pw-frame-drawer:dir(rtl)[data-ending-style] { - transform: translateX(calc(100% + 16px)); - } - .pw-frame-mobile-close { - display: flex; - justify-content: flex-end; - padding: 4px 4px 0; - } - .pw-frame-drawer .pw-frame-brand { - padding-top: 0; - } - .pw-frame-drawer .pw-frame-nav-item, - .pw-app-frame[data-mobile] .pw-frame-toggle { - min-height: 44px; - } - .pw-app-frame[data-mobile] .pw-frame-toggle { - width: 44px; - height: 44px; - } - .pw-frame-skip { - position: fixed; - inset-block-start: -100px; - inset-inline-start: 8px; - padding: 8px 16px; - border-radius: 6px; - background: var(--theme-panel-bg); - color: var(--theme-app); - z-index: var(--layer-popover, 1400); - } - .pw-frame-skip:focus { - inset-block-start: 8px; - } - @media (prefers-reduced-motion: reduce) { - .pw-frame-sidebar, - .pw-frame-backdrop, - .pw-frame-drawer, - .pw-frame-nav-item { - transition: none; - } - } -} +@import "./layout.css"; +@import "./navigation.css"; diff --git a/packages/ui/src/app-frame/layout.css b/packages/ui/src/app-frame/layout.css new file mode 100644 index 00000000..ea632f7b --- /dev/null +++ b/packages/ui/src/app-frame/layout.css @@ -0,0 +1,222 @@ +@layer pw-ui { + /* The frame uses the same sidebar tokens as the theme generator. */ + .pw-app-frame { + display: flex; + flex-direction: column; + height: 100dvh; + min-height: 0; + isolation: isolate; + background: var(--theme-sidebar-bg, var(--theme-muted-panel-bg)); + color: var(--theme-app); + } + .pw-frame-body { + display: flex; + flex: 1; + min-height: 0; + position: relative; + overflow: hidden; + } + .pw-frame-sidebar, + .pw-frame-drawer { + display: flex; + flex-direction: column; + flex-shrink: 0; + min-height: 0; + background: var(--theme-sidebar-bg, var(--theme-muted-panel-bg)); + color: var(--theme-sidebar-text, var(--theme-app)); + } + .pw-frame-sidebar { + position: relative; + transition: width 200ms cubic-bezier(0.25, 0.1, 0.25, 1); + } + .pw-frame-sidebar[data-resizing] { + transition: none; + user-select: none; + } + .pw-frame-brand { + padding: 12px 12px 4px; + flex-shrink: 0; + min-width: 0; + overflow: hidden; + } + .pw-frame-links { + flex: 1; + overflow-x: hidden; + overflow-y: auto; + padding: 12px; + min-width: 0; + min-height: 0; + scrollbar-width: none; + } + .pw-frame-links::-webkit-scrollbar { + display: none; + } + .pw-frame-links nav { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 4px; + } + .pw-frame-footer { + flex-shrink: 0; + padding: 8px 12px; + } + .pw-frame-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: 0; + border-radius: 8px; + padding: 0; + color: inherit; + background: transparent; + cursor: pointer; + flex-shrink: 0; + } + .pw-frame-toggle:hover { + background: var(--theme-sidebar-hover, var(--theme-hover)); + } + .pw-frame-toggle svg { + width: 18px; + height: 18px; + } + .pw-frame-sidebar :focus-visible, + .pw-frame-drawer :focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; + } + .pw-frame-resize { + position: absolute; + z-index: 1; + inset-block: 0; + inset-inline-end: -4px; + width: 8px; + cursor: col-resize; + touch-action: none; + } + .pw-frame-resize::after { + content: ""; + position: absolute; + inset-block: 8px; + inset-inline-start: 3px; + width: 2px; + border-radius: 2px; + } + .pw-frame-resize:hover::after, + .pw-frame-resize:focus-visible::after { + background: var(--theme-sidebar-text, var(--theme-accent)); + opacity: 0.5; + } + .pw-frame-surface-wrap { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + padding: 8px; + padding-inline-start: 0; + } + .pw-frame-surface { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: hidden; + isolation: isolate; + border: 1px solid var(--theme-border); + border-radius: 12px; + background: var(--theme-app-bg); + background-clip: padding-box; + } + .pw-frame-header, + .pw-frame-banner { + flex-shrink: 0; + } + .pw-frame-main { + flex: 1; + min-width: 0; + min-height: 0; + overflow: auto; + } + .pw-app-frame[data-full-bleed] .pw-frame-surface-wrap { + padding: 0; + } + .pw-app-frame[data-full-bleed] .pw-frame-surface { + border: 0; + border-radius: 0; + } + .pw-app-frame[data-mobile] .pw-frame-surface-wrap { + padding: 8px; + } + .pw-app-frame[data-mobile][data-full-bleed] .pw-frame-surface-wrap { + padding: 0; + } + .pw-frame-backdrop { + position: absolute; + inset: 0; + z-index: var(--layer-drawer, 1100); + background: var(--theme-backdrop, rgb(0 0 0 / 0.35)); + transition: opacity 200ms cubic-bezier(0.25, 0.1, 0.25, 1); + } + .pw-frame-backdrop[data-starting-style], + .pw-frame-backdrop[data-ending-style] { + opacity: 0; + } + .pw-frame-drawer { + position: absolute; + inset-block: 8px; + inset-inline-start: 8px; + max-width: calc(100% - 48px); + z-index: var(--layer-drawer, 1100); + border-radius: 12px; + box-shadow: 0 12px 32px rgb(0 0 0 / 0.2); + outline: none; + transform: translateX(0); + transition: transform 200ms cubic-bezier(0.25, 0.1, 0.25, 1); + } + .pw-frame-drawer[data-starting-style], + .pw-frame-drawer[data-ending-style] { + transform: translateX(calc(-100% - 16px)); + } + .pw-frame-drawer:dir(rtl)[data-starting-style], + .pw-frame-drawer:dir(rtl)[data-ending-style] { + transform: translateX(calc(100% + 16px)); + } + .pw-frame-mobile-close { + display: flex; + justify-content: flex-end; + padding: 4px 4px 0; + } + .pw-frame-drawer .pw-frame-brand { + padding-top: 0; + } + .pw-app-frame[data-mobile] .pw-frame-toggle { + min-height: 44px; + } + .pw-app-frame[data-mobile] .pw-frame-toggle { + width: 44px; + height: 44px; + } + .pw-frame-skip { + position: fixed; + inset-block-start: -100px; + inset-inline-start: 8px; + padding: 8px 16px; + border-radius: 6px; + background: var(--theme-panel-bg); + color: var(--theme-app); + z-index: var(--layer-popover, 1400); + } + .pw-frame-skip:focus { + inset-block-start: 8px; + } + @media (prefers-reduced-motion: reduce) { + .pw-frame-sidebar, + .pw-frame-backdrop, + .pw-frame-drawer { + transition: none; + } + } +} diff --git a/packages/ui/src/app-frame/navigation.css b/packages/ui/src/app-frame/navigation.css new file mode 100644 index 00000000..219c221a --- /dev/null +++ b/packages/ui/src/app-frame/navigation.css @@ -0,0 +1,69 @@ +@layer pw-ui { + .pw-frame-nav-item { + display: flex; + align-items: center; + gap: 8px; + min-height: 36px; + min-width: 0; + width: 100%; + border: 0; + border-radius: 8px; + padding: 8px; + background: transparent; + color: inherit; + font: inherit; + font-size: 14px; + font-weight: 500; + line-height: 20px; + cursor: pointer; + text-decoration: none; + text-align: start; + transition: background-color 150ms ease-out; + } + .pw-frame-nav-item:hover { + background: var(--theme-sidebar-hover, var(--theme-hover)); + } + .pw-frame-nav-item[aria-current] { + background: var(--theme-sidebar-active-bg, var(--theme-accent-subtle)); + color: var(--theme-sidebar-active-text, var(--theme-accent)); + } + .pw-frame-nav-item:is(:disabled, [aria-disabled]) { + opacity: 0.5; + cursor: not-allowed; + } + .pw-frame-nav-icon { + display: flex; + flex: 0 0 18px; + align-items: center; + justify-content: center; + } + .pw-frame-nav-icon svg { + width: 18px; + height: 18px; + } + .pw-frame-nav-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .pw-frame-nav-badge { + flex-shrink: 0; + font-size: 12px; + font-variant-numeric: tabular-nums; + } + [data-sidebar-mode="rail"] .pw-frame-nav-item { + padding-inline: 0; + justify-content: center; + height: 40px; + } + .pw-frame-drawer .pw-frame-nav-item { + min-height: 44px; + } + @media (prefers-reduced-motion: reduce) { + .pw-frame-nav-item { + transition: none; + } + } +}