Source: ddx-documentation/shell-tree.md
Reconciled with the built code (task 043). A component not in this map needs this file updated first (G5.9, AC5.5). `PublicLayout` (scaffold) is retired: `SiteShell` is the only site frame, including for auth pages and every error/not-found state. - Route shape (Q2, G5.13): `/[locale]/(app)/portal/[orgSlug]/[role]/...` with a dynamic org slug. - Site area: `/[locale]/(marketing)/...`; auth pages: `/[locale]/(auth)/...`. - Platform area: `/[locale]/(app)/platform/...` (super-admin only; `platform/settings/page.tsx` edits the platform settings tier). - Owner skills: `ddx-nextjs-shell` (fixed header/footer, scroll contract, mobile menu) · `ddx-nextjs-navigation` (side nav, breadcrumbs, entity routing) · `ddx-nextjs-frontend-layout` (layout tree, provider cascade, gate placement, nav registry). - Chrome lives once, in the shell. Pages never render header, footer, side nav or a second `<h1>` (HMS I1, I12). The role is gated once, in `PortalPage` (invariant 14, HMS I14). ## Tree ```text app/[locale]/layout.tsx <html lang dir data-brand> + Theme > Intl > Query ├── (marketing)/layout.tsx SiteShell │ ├── SiteHeader ── SiteMobileMenu │ ├── SiteMain │ │ └── page.tsx → SitePage → SitePage* family (Form, Card, Table, HeroSection, CTASection, Grid, ...) │ └── SiteFooter ├── (auth)/auth/layout.tsx SiteShell mode=auth (header, locale, theme, mobile menu) + AuthFrame brand panel └── (app)/layout.tsx auth-free (gallery + docs answer 200, AC8.4) ├── gallery/, docs/ own layout (SiteShell docs) + special files ├── portal/page.tsx entry: session → org → member role → redirect to /portal/<slug>/<role> └── portal/[orgSlug]/layout.tsx resolves org + session; Session > Org > Settings > SseScope └── [role]/layout.tsx PortalShell (no role gate here) ├── PortalSideNav (full height: logo + toggle, PortalOrgSwitcher, search, nav, PortalUserMenu) ├── PortalHeader ── PortalMobileMenu (top bar of the main column) ├── PortalMain │ └── page.tsx → PortalPage (variant, role gate ONCE) │ ├── PortalPageHeader (breadcrumbs, title, actions, dockable actions, search, notifications) │ ├── PortalPageSideNav (optional, per-page secondary nav) │ └── PortalPageContent → PortalPage* family ├── PortalStatusLine └── PortalFooter ``` ## SiteShell (task 026) | Component | File | Route segment | Owner skill | Task | |---|---|---|---|---| | SiteShell | `ddx-web/src/components/site/SiteShell.tsx` | `[locale]/(marketing)/layout.tsx` | ddx-nextjs-shell | 026 | | SiteHeader | `ddx-web/src/components/site/SiteHeader.tsx` | `[locale]/(marketing)` | ddx-nextjs-shell | 026 | | SiteMobileMenu | `ddx-web/src/components/site/SiteMobileMenu.tsx` | `[locale]/(marketing)` | ddx-nextjs-shell | 026 | | SiteMain | `ddx-web/src/components/site/SiteMain.tsx` | `[locale]/(marketing)` | ddx-nextjs-shell | 026 | | SiteFooter | `ddx-web/src/components/site/SiteFooter.tsx` | `[locale]/(marketing)` | ddx-nextjs-shell | 026 | | SitePage | `ddx-web/src/components/site/SitePage.tsx` | every `(marketing)/**/page.tsx` | ddx-nextjs-frontend-layout | 026 | | Site nav registry | `ddx-web/src/lib/navigation/site-registry.ts` | `[locale]/(marketing)` | ddx-nextjs-frontend-layout | 026 | ## PortalShell (task 029) | Component | File | Route segment | Owner skill | Task | |---|---|---|---|---| | PortalShell | `ddx-web/src/components/portal/shell/PortalShell.tsx` | `[locale]/(app)/portal/[orgSlug]/[role]/layout.tsx` | ddx-nextjs-shell | 029 | | PortalHeader | `ddx-web/src/components/portal/shell/PortalHeader.tsx` | `portal/[orgSlug]/[role]` | ddx-nextjs-shell | 029 | | PortalMobileMenu | `ddx-web/src/components/portal/shell/PortalMobileMenu.tsx` | `portal/[orgSlug]/[role]` | ddx-nextjs-shell | 029 | | PortalSideNav | `ddx-web/src/components/portal/shell/PortalSideNav.tsx` | `portal/[orgSlug]/[role]` | ddx-nextjs-navigation | 029 | | PortalMain | `ddx-web/src/components/portal/shell/PortalMain.tsx` | `portal/[orgSlug]/[role]` | ddx-nextjs-shell | 029 | | PortalStatusLine | `ddx-web/src/components/portal/shell/PortalStatusLine.tsx` | `portal/[orgSlug]/[role]` | ddx-nextjs-shell | 029 | | PortalFooter | `ddx-web/src/components/portal/shell/PortalFooter.tsx` | `portal/[orgSlug]/[role]` | ddx-nextjs-shell | 029 | | Portal nav registry | `ddx-web/src/lib/navigation/portal-registry.ts` | `portal/[orgSlug]/[role]` | ddx-nextjs-frontend-layout | 029 | | Org + session layout | `ddx-web/src/app/[locale]/(app)/portal/[orgSlug]/layout.tsx` | `portal/[orgSlug]` | ddx-nextjs-frontend-layout | 029 | ## PortalPage core (task 030) | Component | File | Route segment | Owner skill | Task | |---|---|---|---|---| | PortalPage (role gate once) | `ddx-web/src/components/portal/page/PortalPage.tsx` | every `portal/[orgSlug]/[role]/**/page.tsx` | ddx-nextjs-frontend-layout | 030 | | PortalPageHeader | `ddx-web/src/components/portal/page/PortalPageHeader.tsx` | page level | ddx-nextjs-navigation (breadcrumbs) | 030 | | PortalPageContent | `ddx-web/src/components/portal/page/PortalPageContent.tsx` | page level | ddx-nextjs-shell | 030 | | PortalPageSideNav | `ddx-web/src/components/portal/page/PortalPageSideNav.tsx` | page level | ddx-nextjs-navigation | 030 | ### PortalPage variants (ADR 0009 `variant` axis, P1 / P2 / P22) | Variant | Use | Header | SideNav | Content width | |---|---|---|---|---| | `standard` | list, form, detail pages | full | optional | container | | `workspace` | record workspace with panels | full | yes | full, resizable panels | | `section` | sub-section inside a record | compact | inherited | container | | `focus` | wizard, print preview, single task | minimal | no | narrow | | `agentic` | chat / voice / stream surfaces | compact | optional | full, scroll owned by surface | | `fullbleed` | viewer, calendar, room | none or overlay | no | full bleed | All six live in `PortalPage.tsx` as one `cva` definition. File: same as PortalPage; Task 030. ## Provider cascade (7 providers) Order: **Theme > Intl > Query > Session > Org > Settings > SseScope** (the root `SseBusProvider` mounts once above, in `(app)/layout.tsx`). Each provider mounts once (`ddx-nextjs-frontend-layout` D3). Brand is the server `data-brand` attribute, not a provider. | # | Provider | Mounted in | Site area | Portal area | |---|---|---|---|---| | 1 | ThemeProvider (`next-themes`) | `app/[locale]/layout.tsx` | YES | YES | | 2 | NextIntlClientProvider | `app/[locale]/layout.tsx` | YES | YES | | 3 | QueryProvider (TanStack, per-tab client) | `app/[locale]/layout.tsx` | YES | YES | | 4 | SessionProvider | `app/[locale]/(app)/portal/[orgSlug]/layout.tsx` | no | YES | | 5 | OrgProvider | `app/[locale]/(app)/portal/[orgSlug]/layout.tsx` | no | YES | | 6 | UnifiedSettingsProvider | `app/[locale]/(app)/portal/[orgSlug]/layout.tsx` | no | YES | | 7 | SseScope (sets the scope on the root `SseBusProvider`, which wraps `lib/sse/manager.ts`) | `app/[locale]/(app)/portal/[orgSlug]/layout.tsx` (provider: `(app)/layout.tsx`) | no | YES | - Site: providers 1-3. Portal: all seven. - Providers 4-7 sit inside `<Suspense>`; the layout passes un-awaited DAL promises, clients unwrap with `use()` (`ddx-server-client-boundary`). - Deviation from the skill text: the skill places 4-7 in `(app)/layout.tsx`. Here `(app)` stays auth-free (gallery and docs must answer 200, AC8.4), so 4-7 move one level down to `portal/[orgSlug]/layout.tsx` (shard 04 design decision). Still one mount each. - Service container: `(app)/layout.tsx` mounts one `ClientContainerProvider` (`ddx-web/src/lib/di/client-container.tsx`, ADR 0007) around the gallery, docs and portal trees; components resolve services with `useService(key)`. ## Segment files (G5.7) Rule (Next 16.4): a boundary renders inside its PARENT layouts only. So each state file sits in the segment whose shell should frame it, and composes inventory entries. | Raised in | Boundary files | Frame | |---|---|---| | unmatched `/[locale]/*`, `[locale]` error / 403 / 401 | `[locale]/{not-found,error,forbidden,unauthorized,loading}.tsx` | own SiteShell (`auth` mode for 401) | | marketing page | `(marketing)/{not-found,error,loading}.tsx` | SiteShell | | gallery / docs page | `(app)/{gallery,docs}/{not-found,error,loading}.tsx` | SiteShell `docs` | | unknown `/auth/*`, auth page error / load | `(auth)/auth/{not-found,error,loading}.tsx` + `[...rest]/page.tsx` | SiteShell auth card | | `portal/[orgSlug]` layout (unknown org, non-member, no session, DAL 403, API down) | `portal/{not-found,unauthorized,forbidden,error}.tsx` | own SiteShell (no PortalShell can exist without a resolved org) | | `[role]` layout error | `portal/[orgSlug]/error.tsx` | own SiteShell | | PortalPage `forbidden()` / `unauthorized()` | `[role]/{forbidden,unauthorized}.tsx` | PortalShell | | `[role]` pages error / load / 404 | `[role]/{error,loading,not-found}.tsx` | PortalShell (PortalError / PortalLoading / PortalNotFound) | Shared composition: `app/[locale]/_states/site-route-state.tsx`, `site-route-error.tsx`. `app/global-error.tsx` sits at the root. ## Portal navigation and role gate - `ddx-web/src/lib/navigation/portal-registry.ts` is the one nav registry: 13 item ids (`home projects settings workspace data forms realtime stream chat voice media call schedule`) in per-role sections. Side nav and mobile menu read it; hrefs are built from slug + role + segment. - `rolesFor(id)` returns the roles whose nav shows item `id`. Each nav page passes `allowedRoles: rolesFor('<id>')` to `PortalPage`, so a visible link never renders forbidden. The gate stays in `PortalPage` only (invariant 14); ddx-api still decides data access. ## Shell geometry and side nav - ChatGPT desktop layout (`ddx-web/DESIGN.md` §6): an `h-dvh` row — `PortalSideNav` runs the full height; the main column stacks `PortalHeader` (top bar), the banner, `PortalMain` and `PortalFooter`. Vars in `ddx-web/src/app/globals.css`, derived from `--token-spacing-*`: `--shell-gutter` (`clamp(md, 4vw, xxl)`) is the inline padding of every page frame (`PortalPage` variants in `ddx-web/src/components/portal/page/types.ts`); top bar and footer use `px-md`; `--status-bar-height` (40 px) is the height of `PortalFooter` (muted line, no seam, no shadow); `--nav-row-h` (36 px) is the side-nav / account-menu row. The top bar has one `seam-x`, no shadow; `PortalMain` is flat. - Page frame widths are `max-w-content` (standard, section) and `max-w-prose` (focus); workspace, agentic and fullbleed fill the panel height. - Side nav (`PortalSideNavState`) has three modes: `expanded`, `collapsed` (both follow the saved preference) and `auto` (no saved preference and the shell default is expanded: icon rail from md, expanded from lg). The top-row toggle and ⇧⌘S / Ctrl+Shift+S flip it. Mobile (< md) uses `PortalMobileMenu` (org switcher, search, nav list). ## PortalMain and tables `PortalMain` is the flat content area of the main column; `main` does not scroll. List pages (`data`, `projects`, `media`) run `DataTable` in fill mode: the table takes the parent's height and width, the pager is pinned to the bottom, and when fewer rows than the page size exist, lighter filler skeleton rows complete the page. Below `sm` the pager is one row (range + previous / next). --- Last reconciled: 2026-10-25 · plan harness-alignment task 038
Reconciled with the built code (task 043). A component not in this map needs this file
updated first (G5.9, AC5.5). PublicLayout (scaffold) is retired: SiteShell is the only
site frame, including for auth pages and every error/not-found state.
/[locale]/(app)/portal/[orgSlug]/[role]/... with a dynamic org slug./[locale]/(marketing)/...; auth pages: /[locale]/(auth)/..../[locale]/(app)/platform/... (super-admin only; platform/settings/page.tsx edits the platform settings tier).ddx-nextjs-shell (fixed header/footer, scroll contract, mobile menu) ·
ddx-nextjs-navigation (side nav, breadcrumbs, entity routing) ·
ddx-nextjs-frontend-layout (layout tree, provider cascade, gate placement, nav registry).<h1> (HMS I1, I12). The role is gated once, in PortalPage (invariant 14, HMS I14).variant axis, P1 / P2 / P22)All six live in PortalPage.tsx as one cva definition. File: same as PortalPage; Task 030.
Order: Theme > Intl > Query > Session > Org > Settings > SseScope (the root SseBusProvider mounts once above, in (app)/layout.tsx). Each provider mounts once
(ddx-nextjs-frontend-layout D3). Brand is the server data-brand attribute, not a provider.
<Suspense>; the layout passes un-awaited DAL promises, clients
unwrap with use() (ddx-server-client-boundary).(app)/layout.tsx. Here (app) stays
auth-free (gallery and docs must answer 200, AC8.4), so 4-7 move one level down to
portal/[orgSlug]/layout.tsx (shard 04 design decision). Still one mount each.(app)/layout.tsx mounts one ClientContainerProvider
(ddx-web/src/lib/di/client-container.tsx, ADR 0007) around the gallery, docs and portal
trees; components resolve services with useService(key).Rule (Next 16.4): a boundary renders inside its PARENT layouts only. So each state file sits in the segment whose shell should frame it, and composes inventory entries.
Shared composition: app/[locale]/_states/site-route-state.tsx, site-route-error.tsx.
app/global-error.tsx sits at the root.
ddx-web/src/lib/navigation/portal-registry.ts is the one nav registry: 13 item ids
(home projects settings workspace data forms realtime stream chat voice media call schedule)
in per-role sections. Side nav and mobile menu read it; hrefs are built from slug + role + segment.rolesFor(id) returns the roles whose nav shows item id. Each nav page passes
allowedRoles: rolesFor('<id>') to PortalPage, so a visible link never renders forbidden.
The gate stays in PortalPage only (invariant 14); ddx-api still decides data access.ddx-web/DESIGN.md §6): an h-dvh row — PortalSideNav runs the
full height; the main column stacks PortalHeader (top bar), the banner, PortalMain and
PortalFooter. Vars in ddx-web/src/app/globals.css, derived from --token-spacing-*:
--shell-gutter (clamp(md, 4vw, xxl)) is the inline padding of every page frame
(PortalPage variants in ddx-web/src/components/portal/page/types.ts); top bar and footer
use px-md; --status-bar-height (40 px) is the height of PortalFooter (muted line, no
seam, no shadow); --nav-row-h (36 px) is the side-nav / account-menu row. The top bar has
one seam-x, no shadow; PortalMain is flat.max-w-content (standard, section) and max-w-prose (focus); workspace, agentic and fullbleed fill the panel height.PortalSideNavState) has three modes: expanded, collapsed (both follow the
saved preference) and auto (no saved preference and the shell default is expanded: icon
rail from md, expanded from lg). The top-row toggle and ⇧⌘S / Ctrl+Shift+S flip it. Mobile
(< md) uses PortalMobileMenu (org switcher, search, nav list).PortalMain is the flat content area of the main column; main does not scroll.
List pages (data, projects, media) run DataTable in fill mode: the table takes the
parent's height and width, the pager is pinned to the bottom, and when fewer rows than the
page size exist, lighter filler skeleton rows complete the page. Below sm the pager is one
row (range + previous / next).
Last reconciled: 2026-10-25 · plan harness-alignment task 038
adr/
deploy/
research/
app/[locale]/layout.tsx <html lang dir data-brand> + Theme > Intl > Query├── (marketing)/layout.tsx SiteShell│ ├── SiteHeader ── SiteMobileMenu│ ├── SiteMain│ │ └── page.tsx → SitePage → SitePage* family (Form, Card, Table, HeroSection, CTASection, Grid, ...)│ └── SiteFooter├── (auth)/auth/layout.tsx SiteShell mode=auth (header, locale, theme, mobile menu) + AuthFrame brand panel└── (app)/layout.tsx auth-free (gallery + docs answer 200, AC8.4) ├── gallery/, docs/ own layout (SiteShell docs) + special files ├── portal/page.tsx entry: session → org → member role → redirect to /portal/<slug>/<role> └── portal/[orgSlug]/layout.tsx resolves org + session; Session > Org > Settings > SseScope └── [role]/layout.tsx PortalShell (no role gate here) ├── PortalSideNav (full height: logo + toggle, PortalOrgSwitcher, search, nav, PortalUserMenu) ├── PortalHeader ── PortalMobileMenu (top bar of the main column) ├── PortalMain │ └── page.tsx → PortalPage (variant, role gate ONCE) │ ├── PortalPageHeader (breadcrumbs, title, actions, dockable actions, search, notifications) │ ├── PortalPageSideNav (optional, per-page secondary nav) │ └── PortalPageContent → PortalPage* family ├── PortalStatusLine └── PortalFooter