Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1326,3 +1326,20 @@ link; put page content inside it without nesting a second `<main>`.
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.
10 changes: 7 additions & 3 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": [
Expand All @@ -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",
Expand Down
58 changes: 58 additions & 0 deletions packages/ui/scripts/verify-pack.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand All @@ -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'))
Expand Down Expand Up @@ -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'),
Expand Down
61 changes: 61 additions & 0 deletions packages/ui/src/app-frame/AppFrame.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<AppFrame
{...args}
navigationKey={active}
brand={({ expanded }) => <strong>{expanded ? 'Workspace' : 'W'}</strong>}
navigation={({ expanded, isMobile }) => (
<nav aria-label="Main navigation">
{[
{ label: 'Overview', icon: GridIcon },
{ label: 'Projects', icon: ListViewIcon },
{ label: 'Settings', icon: SettingsIcon },
].map(({ label, icon: Icon }) => (
<button
key={label}
type="button"
aria-label={label}
aria-current={active === label ? 'page' : undefined}
onClick={() => setActive(label)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: expanded ? 'start' : 'center',
gap: 8,
minHeight: isMobile ? 44 : 40,
padding: expanded ? '8px 12px' : 0,
border: '1px solid var(--theme-border)',
borderRadius: 4,
background: active === label ? 'var(--theme-sidebar-active-bg)' : 'transparent',
color: active === label ? 'var(--theme-sidebar-active-text)' : 'inherit',
cursor: 'pointer',
}}
>
<Icon style={{ width: 18, height: 18, flexShrink: 0 }} />
{expanded && label}
</button>
))}
</nav>
)}
footer={({ isMobile }) => !isMobile && <AppFrameToggle />}
header={<Header />}
>
<div style={{ padding: 24 }}>
<h2 style={{ fontSize: 20, marginBottom: 8 }}>{active}</h2>
<p>The host styles these navigation buttons. The frame owns layout and its controls.</p>
</div>
</AppFrame>
)
}
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 } },
}
Expand Down
Loading
Loading