// Per-route view wrapper components for DashboardRouterView (02-02 fix). // // WHY THIS FILE EXISTS — the visual contract that broke at the Task 3 // checkpoint and must never break again: // // dashboard-styles.css scopes EVERY route transition as a compound selector, // e.g. `.slide-up-enter-active.view-wrapper`, `.depth-forward-enter-from.view-wrapper`. // That means Vue's transition classes and the `view-wrapper` class MUST land on // the SAME element, and `.view-wrapper` (position: absolute; inset: 0) must be // the direct child of `.perspective-container` so the 3D perspective chain for // the depth transitions is intact and nothing clips the slide movement. // `.view-wrapper` must also stay OUTSIDE the padded/scrollable per-route // wrapper divs — putting `view-wrapper` on the view's own root inside the // padded wrapper pins the view over the page padding (broken margins). // // So the transitioning, keyed element has to be `div.view-wrapper` containing // the per-route wrapper shape — exactly the pre-02-02 Dashboard.vue markup. // To ALSO get KeepAlive instance caching, that keyed div must be a cached // component's own root. These statically-defined wrapper components provide // that root. Unlike the async route components (RESEARCH A1 / vuejs/core // #11764), statically-defined components have reliable `name`s, so KeepAlive's // `include` can gate cache membership by wrapper name. import { defineComponent, h, type Component, type PropType } from 'vue' import { KEEP_ALIVE_PATHS, shouldKeepAlive } from './keepAliveRoutes' /** Name prefix for cacheable wrappers — what KeepAlive's `include` matches. */ export const KEEP_WRAP_PREFIX = 'KeepWrap:' /** Routes that render edge-to-edge (no page padding). */ export function isFullBleedPath(path: string): boolean { return path === '/dashboard/chat' || path === '/dashboard/mesh' } /** Component names KeepAlive should instance-cache, derived from * KEEP_ALIVE_PATHS so route classification stays single-sourced. */ export function keepAliveIncludeNames(): string[] { return Array.from(KEEP_ALIVE_PATHS, (path) => KEEP_WRAP_PREFIX + path) } /** One wrapper component per kept-alive path and per full-bleed path; a single * shared component for every other (default-shape, uncached) route. Keying the * cache this way keeps it bounded — detail routes with unbounded param paths * (`/dashboard/marketplace/:id`, …) all reuse the shared default wrapper, and * Transition/KeepAlive still distinguish them via `:key="route.path"` on the * wrapper vnode. */ const DEFAULT_WRAPPER_KEY = 'default' const wrapperCache = new Map() function makeWrapper(path: string, cacheable: boolean): Component { const fullBleed = isFullBleedPath(path) return defineComponent({ // Cacheable wrappers get the include-matched name; everything else shares // a name that never matches `include`, so it is never instance-cached. name: cacheable ? KEEP_WRAP_PREFIX + path : 'DashboardViewWrapper', props: { mobileTabPaddingTop: { type: Number as PropType, default: null }, needsMobileBackButtonSpace: { type: Boolean, default: false }, }, setup(props, { slots }) { // Renders the pre-02-02 Dashboard.vue markup byte-for-byte: // div.view-wrapper <- transition surface, absolute inset-0 // div (full-bleed OR padded shape) <- page margins / scroll container // [+ spacer] return () => { const paddingStyle = props.mobileTabPaddingTop ? { paddingTop: `${props.mobileTabPaddingTop + 16}px` } : undefined const inner = fullBleed ? h( 'div', { class: [ 'h-full', path === '/dashboard/mesh' ? 'dashboard-scroll-panel mobile-scroll-pad mesh-dashboard-panel' : '', props.mobileTabPaddingTop ? 'overflow-y-auto' : '', 'mobile-safe-top', ], style: paddingStyle, }, slots.default?.(), ) : h( 'div', { class: [ 'absolute inset-0 px-4 pt-4 md:pt-8 md:px-8 overflow-y-auto mobile-safe-top dashboard-scroll-panel', props.needsMobileBackButtonSpace ? 'mobile-scroll-pad-back' : 'mobile-scroll-pad', ], style: paddingStyle, }, [ slots.default?.(), // Bottom spacer — scroll clearance on all pages h('div', { class: 'shrink-0 h-6 md:h-12', 'aria-hidden': 'true' }), ], ) return h('div', { class: 'view-wrapper' }, [inner]) } }, }) } /** Memoized wrapper lookup — stable component identity per route so unrelated * re-renders never remount the current view. */ export function wrapperFor(path: string): Component { const cacheable = shouldKeepAlive({ path }) // The `isFullBleedPath(path)` half of this check is currently unreachable // (IN-02): isFullBleedPath() only returns true for /dashboard/chat and // /dashboard/mesh, both always in KEEP_ALIVE_PATHS today, so `cacheable` // alone already covers every path that reaches this branch. Kept as // deliberate defense against a future TAB_ORDER/WITHHELD_FROM_CACHE change // that could withhold a full-bleed path from the cache — not dead code to // delete. const key = cacheable || isFullBleedPath(path) ? path : DEFAULT_WRAPPER_KEY let wrapper = wrapperCache.get(key) if (!wrapper) { wrapper = makeWrapper(path, cacheable) wrapperCache.set(key, wrapper) } return wrapper }