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
51 changes: 51 additions & 0 deletions docs/increments/active/2026-07-27-page-rhythm-follow-up/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Page rhythm follow-up

## Problem

The shared page system shipped the correct width, gutter, header, canvas, and
table primitives, but ordinary page content was still rendered directly under
`PageLayout.Root`. `PageLayout.Body` existed and was not used by any feature.
As a result, independent controls and surfaces visually touched:

- the Asset catalog filter touched the first card row;
- Administration cards touched the following directory search;
- the MCP client tabs and capability callout touched adjacent cards;
- the same missing body rhythm affected Asset detail, Pack journey, governance,
and user-permission pages.

This is a layout-contract defect, not an intended stacked-card treatment. The
surfaces retain independent rounded borders, so zero spacing communicates a
relationship the product does not have.

## Reference evidence

The local Onyx reference keeps settings chrome and page content separate:

- `SettingsLayouts.Header` owns heading-level controls;
- `SettingsLayouts.Body` adds top padding and a consistent vertical gap;
- list toolbars add an explicit bottom gap before cards or tables;
- persistent protocol facts remain ordinary content; `MessageCard` is reserved
for contextual, actionable notices.

OrgMemory keeps its own visual language. It adopts the composition contract,
not Onyx styling.

## Decision

1. `PageLayout.Body` owns `pt-6` and `gap-6`.
2. `AdminPage` wraps all administration content in that body once.
3. Ordinary Asset and MCP pages explicitly compose `Header` then `Body`.
4. Asset catalog filters live in the page header because they control the whole
catalog; the result grid lives in the body.
5. The MCP page removes the hard-coded `Available` status and decorative
protocol badges. Read-only OAuth is stated once in the endpoint description.
Permission and mutation limits remain as one subdued supporting sentence.
6. Canvas and Sources tab layouts keep their specialized spacing contracts.

## Verification

- focused PageLayout component test;
- Asset golden-flow browser assertion that the filter and result grid do not
touch;
- MCP browser assertions for the simplified hierarchy;
- frontend lint, typecheck, production build, Vitest, and Playwright.
13 changes: 13 additions & 0 deletions docs/increments/active/2026-07-27-page-rhythm-follow-up/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Page rhythm follow-up plan

- [x] Audit current screenshots, shared layout source, and local Onyx
composition primitives.
- [x] Make `PageLayout.Body` the ordinary content rhythm and apply it once in
`AdminPage`.
- [x] Migrate Asset catalog, Asset detail/state, Pack journey, governance,
user permissions, and MCP onboarding to the body contract.
- [x] Remove hard-coded MCP availability/protocol badges and replace the large
static security callout with subdued supporting copy.
- [x] Add focused regression coverage.
- [x] Run frontend static, unit, build, and real-browser gates.
- [ ] Resolve review findings, merge, deploy, and record runtime evidence.
2 changes: 1 addition & 1 deletion web/src/components/layouts/page-layout-components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export function PageBody({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="page-body"
className={cn("flex min-w-0 flex-col gap-6", className)}
className={cn("flex min-w-0 flex-col gap-6 pt-6", className)}
{...props}
/>
)
Expand Down
16 changes: 16 additions & 0 deletions web/src/components/layouts/page-layout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,20 @@ describe("PageLayout", () => {
screen.getByRole("region", { name: "Knowledge graph explorer" }),
).toHaveAttribute("data-slot", "page-canvas")
})

it("keeps ordinary page content in a dedicated body region", () => {
render(
<PageLayout.Root>
<PageLayout.Header title="Users" />
<PageLayout.Body>
<section aria-label="User directory">Directory</section>
</PageLayout.Body>
</PageLayout.Root>,
)

const body = screen.getByText("Directory").closest('[data-slot="page-body"]')

expect(body).toBeInTheDocument()
expect(body).toHaveClass("pt-6")
})
})
2 changes: 1 addition & 1 deletion web/src/features/admin/components/admin-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function AdminPage({
return (
<PageLayout.Root variant="wide">
<PageLayout.Header title={title} description={description} icon={icon} actions={actions} />
{children}
<PageLayout.Body>{children}</PageLayout.Body>
</PageLayout.Root>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,9 @@ export function AdminUserPermissionsPage({
}
/>

<div className="grid gap-6 lg:grid-cols-2">
<Card>
<PageLayout.Body>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Organization permissions</CardTitle>
</CardHeader>
Expand Down Expand Up @@ -145,9 +146,9 @@ export function AdminUserPermissionsPage({
</>
)}
</CardContent>
</Card>
</Card>

<Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2">
Roles
Expand Down Expand Up @@ -231,10 +232,11 @@ export function AdminUserPermissionsPage({
</>
)}
</CardContent>
</Card>
</div>
</Card>
</div>

<AccessInspector userId={userId} />
<AccessInspector userId={userId} />
</PageLayout.Body>
</PageLayout.Root>
)
}
216 changes: 109 additions & 107 deletions web/src/features/assets/components/asset-catalog-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,117 +55,119 @@ export function AssetCatalogPage({

return (
<PageLayout.Root variant="wide">
<PageLayout.Header title="For your role" />
<PageLayout.Header title="For your role">
<FilterBar
search={
<InputGroup>
<InputGroupAddon>
<Search aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
value={query}
onChange={(event) => onQueryChange(event.currentTarget.value)}
placeholder="Search by task, role, or outcome"
aria-label="Search visible assets"
/>
</InputGroup>
}
filters={
<Select
value={type ?? "ALL"}
onValueChange={(value: string) =>
onTypeChange(value === "ALL" ? undefined : (value as AssetType))
}
>
<SelectTrigger aria-label="Filter asset type" className="w-full">
<SelectValue placeholder="All asset types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All asset types</SelectItem>
{ASSET_TYPES.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
}
/>
</PageLayout.Header>

<FilterBar
search={
<InputGroup>
<InputGroupAddon>
<Search aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
value={query}
onChange={(event) => onQueryChange(event.currentTarget.value)}
placeholder="Search by task, role, or outcome"
aria-label="Search visible assets"
<PageLayout.Body>
{recommendations.length === 0 ? (
<Card className="border-dashed bg-surface-subtle">
<EmptyState
title={hasFilters ? "No matches" : "No assets available"}
action={
hasFilters ? (
<Button
variant="outline"
onClick={() => {
onQueryChange("")
onTypeChange(undefined)
}}
>
Clear filters
</Button>
) : undefined
}
/>
</InputGroup>
}
filters={
<Select
value={type ?? "ALL"}
onValueChange={(value: string) =>
onTypeChange(value === "ALL" ? undefined : (value as AssetType))
}
>
<SelectTrigger aria-label="Filter asset type" className="w-full">
<SelectValue placeholder="All asset types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All asset types</SelectItem>
{ASSET_TYPES.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
}
/>

{recommendations.length === 0 ? (
<Card className="border-dashed bg-surface-subtle">
<EmptyState
title={hasFilters ? "No matches" : "No assets available"}
action={
hasFilters ? (
<Button
variant="outline"
onClick={() => {
onQueryChange("")
onTypeChange(undefined)
}}
</Card>
) : (
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-3" aria-label="Visible assets">
{recommendations.map((asset) => {
if (!asset.assetId || !asset.releaseId || !asset.type) return null
const meta = ASSET_TYPE_META[asset.type]
const Icon = meta.icon
return (
<Card
key={`${asset.assetId}:${asset.releaseId}`}
className="group overflow-hidden border-border-default bg-surface-raised transition-[border-color,transform,box-shadow] hover:-translate-y-0.5 hover:border-border-strong hover:shadow-md"
>
Clear filters
</Button>
) : undefined
}
/>
</Card>
) : (
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-3" aria-label="Visible assets">
{recommendations.map((asset) => {
if (!asset.assetId || !asset.releaseId || !asset.type) return null
const meta = ASSET_TYPE_META[asset.type]
const Icon = meta.icon
return (
<Card
key={`${asset.assetId}:${asset.releaseId}`}
className="group overflow-hidden border-border-default bg-surface-raised transition-[border-color,transform,box-shadow] hover:-translate-y-0.5 hover:border-border-strong hover:shadow-md"
>
<CardHeader className="gap-4">
<div className="flex items-start justify-between gap-3">
<span className={`grid size-10 place-items-center rounded-xl ${meta.tone}`}>
<Icon className="size-5" aria-hidden="true" />
</span>
<Badge variant="outline" className="font-mono text-metadata">
{asset.versionLabel}
</Badge>
</div>
<div>
<p className="text-metadata font-mono text-content-muted">
{formatAssetCoordinate(asset)}
</p>
<h2 className="mt-2 text-section-title text-content-primary">{asset.title}</h2>
</div>
</CardHeader>
<CardContent>
<p className="line-clamp-3 text-body text-content-secondary">{asset.summary}</p>
<div className="mt-5 flex flex-wrap gap-2">
<Badge className={meta.tone}>{meta.label}</Badge>
{asset.availability === "DEPRECATED" ? (
<Badge className="bg-status-warning-surface text-status-warning-content">
Update available
<CardHeader className="gap-4">
<div className="flex items-start justify-between gap-3">
<span className={`grid size-10 place-items-center rounded-xl ${meta.tone}`}>
<Icon className="size-5" aria-hidden="true" />
</span>
<Badge variant="outline" className="font-mono text-metadata">
{asset.versionLabel}
</Badge>
) : null}
</div>
</CardContent>
<CardFooter className="border-t border-border-subtle bg-surface-subtle/50 p-0">
<Link
to="/assets/$assetId"
params={{ assetId: asset.assetId }}
search={{ release: asset.releaseId }}
className="flex w-full items-center justify-between px-6 py-4 text-label text-content-primary outline-none transition-colors hover:bg-action-ghost-hover focus-visible:ring-2 focus-visible:ring-focus-ring"
>
Use exact release
<ArrowUpRight className="size-4 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</Link>
</CardFooter>
</Card>
)
})}
</section>
)}
</div>
<div>
<p className="text-metadata font-mono text-content-muted">
{formatAssetCoordinate(asset)}
</p>
<h2 className="mt-2 text-section-title text-content-primary">{asset.title}</h2>
</div>
</CardHeader>
<CardContent>
<p className="line-clamp-3 text-body text-content-secondary">{asset.summary}</p>
<div className="mt-5 flex flex-wrap gap-2">
<Badge className={meta.tone}>{meta.label}</Badge>
{asset.availability === "DEPRECATED" ? (
<Badge className="bg-status-warning-surface text-status-warning-content">
Update available
</Badge>
) : null}
</div>
</CardContent>
<CardFooter className="border-t border-border-subtle bg-surface-subtle/50 p-0">
<Link
to="/assets/$assetId"
params={{ assetId: asset.assetId }}
search={{ release: asset.releaseId }}
className="flex w-full items-center justify-between px-6 py-4 text-label text-content-primary outline-none transition-colors hover:bg-action-ghost-hover focus-visible:ring-2 focus-visible:ring-focus-ring"
>
Use exact release
<ArrowUpRight className="size-4 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</Link>
</CardFooter>
</Card>
)
})}
</section>
)}
</PageLayout.Body>
</PageLayout.Root>
)
}
Loading