feat(shell): fluid layout with collapsible sidebar drawer + fix query-tool MultiSelect

Convert portal shell from block-centered (max-width 1600px) layout to full-viewport
fluid flexbox with collapsible sidebar: desktop push-mode (240px → 0), mobile overlay
drawer with backdrop. Rename .content → .shell-content to avoid CSS collision with
page-level classes. Override page-level max-width constraints when embedded in shell.

Also replace native <select multiple> in query-tool with shared MultiSelect component
for equipment and workcenter group filters, matching resource-status/history UX.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-02-11 18:04:55 +08:00
parent 1e7f8f4498
commit 35d83d424c
17 changed files with 826 additions and 53 deletions

View File

@@ -1,10 +1,17 @@
<script setup> <script setup>
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import HealthStatus from './components/HealthStatus.vue'; import HealthStatus from './components/HealthStatus.vue';
import { consumeNavigationNotice, syncNavigationRoutes } from './router.js'; import { consumeNavigationNotice, syncNavigationRoutes } from './router.js';
import { normalizeRoutePath } from './routeContracts.js'; import { normalizeRoutePath } from './routeContracts.js';
import {
SIDEBAR_STORAGE_KEY,
buildSidebarUiState,
isMobileViewport,
parseSidebarCollapsedPreference,
serializeSidebarCollapsedPreference,
} from './sidebarState.js';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -20,6 +27,9 @@ const adminLinks = ref({
pages: '/admin/pages', pages: '/admin/pages',
performance: '/admin/performance', performance: '/admin/performance',
}); });
const sidebarCollapsed = ref(false);
const sidebarMobileOpen = ref(false);
const isMobile = ref(false);
function toShellPath(targetRoute) { function toShellPath(targetRoute) {
return normalizeRoutePath(targetRoute); return normalizeRoutePath(targetRoute);
@@ -46,6 +56,71 @@ const adminLoginHref = computed(() => {
return `/admin/login?next=${encodeURIComponent('/portal-shell')}`; return `/admin/login?next=${encodeURIComponent('/portal-shell')}`;
}); });
const sidebarUiState = computed(() =>
buildSidebarUiState({
isMobile: isMobile.value,
sidebarCollapsed: sidebarCollapsed.value,
sidebarMobileOpen: sidebarMobileOpen.value,
}),
);
const sidebarToggleLabel = computed(() => {
if (isMobile.value) {
return sidebarMobileOpen.value ? '關閉側邊欄' : '開啟側邊欄';
}
return sidebarCollapsed.value ? '展開側邊欄' : '收合側邊欄';
});
function restoreSidebarPreference() {
try {
const stored = window.sessionStorage.getItem(SIDEBAR_STORAGE_KEY);
sidebarCollapsed.value = parseSidebarCollapsedPreference(stored);
} catch {
sidebarCollapsed.value = false;
}
}
function persistSidebarPreference() {
try {
window.sessionStorage.setItem(
SIDEBAR_STORAGE_KEY,
serializeSidebarCollapsedPreference(sidebarCollapsed.value),
);
} catch {
// Keep UI behavior deterministic even if storage is unavailable.
}
}
function checkViewport() {
isMobile.value = isMobileViewport(window.innerWidth);
if (!isMobile.value) {
sidebarMobileOpen.value = false;
}
}
function closeMobileSidebar() {
sidebarMobileOpen.value = false;
}
function toggleSidebar() {
if (isMobile.value) {
sidebarMobileOpen.value = !sidebarMobileOpen.value;
return;
}
sidebarCollapsed.value = !sidebarCollapsed.value;
persistSidebarPreference();
}
function handleViewportResize() {
checkViewport();
}
function handleGlobalKeydown(event) {
if (event.key === 'Escape') {
closeMobileSidebar();
}
}
async function loadNavigation() { async function loadNavigation() {
loading.value = true; loading.value = true;
errorMessage.value = ''; errorMessage.value = '';
@@ -111,12 +186,22 @@ async function loadNavigation() {
} }
onMounted(() => { onMounted(() => {
restoreSidebarPreference();
checkViewport();
window.addEventListener('resize', handleViewportResize, { passive: true });
window.addEventListener('keydown', handleGlobalKeydown);
void loadNavigation(); void loadNavigation();
}); });
onUnmounted(() => {
window.removeEventListener('resize', handleViewportResize);
window.removeEventListener('keydown', handleGlobalKeydown);
});
watch( watch(
() => route.fullPath, () => route.fullPath,
() => { () => {
closeMobileSidebar();
navigationNotice.value = consumeNavigationNotice(); navigationNotice.value = consumeNavigationNotice();
}, },
{ immediate: true }, { immediate: true },
@@ -124,9 +209,27 @@ watch(
</script> </script>
<template> <template>
<div class="shell"> <div class="shell" :class="sidebarUiState.shellClass">
<header class="shell-header"> <header class="shell-header">
<div> <div class="shell-header-left">
<button
type="button"
class="sidebar-toggle"
:aria-expanded="sidebarUiState.ariaExpanded"
:aria-label="sidebarToggleLabel"
@click="toggleSidebar"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M4 6h16M4 12h16M4 18h16"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</button>
<h1>MES 報表入口</h1> <h1>MES 報表入口</h1>
</div> </div>
<div class="shell-header-right"> <div class="shell-header-right">
@@ -144,8 +247,12 @@ watch(
</div> </div>
</header> </header>
<Transition name="overlay-fade">
<div v-if="isMobile && sidebarMobileOpen" class="sidebar-overlay" @click="closeMobileSidebar" />
</Transition>
<main class="shell-main"> <main class="shell-main">
<aside class="sidebar"> <aside class="sidebar" :class="sidebarUiState.sidebarClass">
<div v-if="loading" class="muted">載入導覽中...</div> <div v-if="loading" class="muted">載入導覽中...</div>
<div v-else-if="errorMessage" class="error">{{ errorMessage }}</div> <div v-else-if="errorMessage" class="error">{{ errorMessage }}</div>
<template v-else> <template v-else>
@@ -164,7 +271,7 @@ watch(
</template> </template>
</aside> </aside>
<section class="content"> <section class="shell-content">
<div v-if="navigationNotice" class="notice-banner">{{ navigationNotice }}</div> <div v-if="navigationNotice" class="notice-banner">{{ navigationNotice }}</div>
<div class="breadcrumb"> <div class="breadcrumb">
<span v-if="breadcrumb.drawerName">{{ breadcrumb.drawerName }}</span> <span v-if="breadcrumb.drawerName">{{ breadcrumb.drawerName }}</span>

View File

@@ -56,7 +56,7 @@ const NATIVE_MODULE_LOADERS = Object.freeze({
), ),
'/query-tool': createNativeLoader( '/query-tool': createNativeLoader(
() => import('../query-tool/App.vue'), () => import('../query-tool/App.vue'),
[() => import('../query-tool/style.css')], [() => import('../resource-shared/styles.css'), () => import('../query-tool/style.css')],
), ),
'/tmtt-defect': createNativeLoader( '/tmtt-defect': createNativeLoader(
() => import('../tmtt-defect/App.vue'), () => import('../tmtt-defect/App.vue'),

View File

@@ -0,0 +1,34 @@
export const SIDEBAR_STORAGE_KEY = 'portal-shell:sidebar-collapsed';
export const MOBILE_BREAKPOINT = 900;
export function isMobileViewport(width, breakpoint = MOBILE_BREAKPOINT) {
return Number(width) <= breakpoint;
}
export function parseSidebarCollapsedPreference(value) {
return String(value).trim() === 'true';
}
export function serializeSidebarCollapsedPreference(collapsed) {
return collapsed ? 'true' : 'false';
}
export function buildSidebarUiState({ isMobile, sidebarCollapsed, sidebarMobileOpen }) {
const mobile = Boolean(isMobile);
const desktopCollapsed = !mobile && Boolean(sidebarCollapsed);
const mobileOpen = mobile && Boolean(sidebarMobileOpen);
const sidebarVisible = mobile ? mobileOpen : !desktopCollapsed;
return {
shellClass: {
'sidebar-collapsed': desktopCollapsed,
},
sidebarClass: {
'sidebar--collapsed': desktopCollapsed,
'sidebar--mobile-open': mobileOpen,
'sidebar--mobile-closed': mobile && !mobileOpen,
},
sidebarVisible,
ariaExpanded: sidebarVisible ? 'true' : 'false',
};
}

View File

@@ -15,9 +15,8 @@ body {
.shell { .shell {
min-height: 100vh; min-height: 100vh;
padding: 20px; display: flex;
max-width: 1600px; flex-direction: column;
margin: 0 auto;
} }
.shell-header { .shell-header {
@@ -27,8 +26,15 @@ body {
gap: 12px; gap: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff; color: #fff;
border-radius: 12px; padding: 12px 20px;
padding: 20px; min-height: var(--shell-header-height, 56px);
flex-shrink: 0;
}
.shell-header-left {
display: inline-flex;
align-items: center;
gap: 12px;
} }
.shell-header h1 { .shell-header h1 {
@@ -47,6 +53,39 @@ body {
gap: 12px; gap: 12px;
} }
.sidebar-toggle {
width: 36px;
height: 36px;
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 8px;
background: rgba(255, 255, 255, 0.12);
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease;
}
.sidebar-toggle:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.55);
}
.sidebar-toggle:active {
transform: translateY(1px);
}
.sidebar-toggle:focus-visible {
outline: 2px solid #fff;
outline-offset: 2px;
}
.sidebar-toggle svg {
width: 18px;
height: 18px;
}
.admin-entry { .admin-entry {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -73,20 +112,77 @@ body {
} }
.shell-main { .shell-main {
margin-top: 14px; flex: 1;
display: grid; min-height: 0;
grid-template-columns: 220px minmax(0, 1fr); display: flex;
gap: 12px; overflow: hidden;
} }
.sidebar { .sidebar {
background: #fff; background: #fff;
border: 1px solid #e5e7eb; width: var(--portal-sidebar-width, 240px);
border-radius: 10px; min-width: var(--portal-sidebar-width, 240px);
border-right: 1px solid #e5e7eb;
padding: 10px; padding: 10px;
height: fit-content; overflow-y: auto;
position: sticky; overflow-x: hidden;
top: 20px; white-space: nowrap;
flex-shrink: 0;
transition:
width 0.3s ease,
min-width 0.3s ease,
padding 0.3s ease,
transform 0.3s ease,
box-shadow 0.3s ease;
}
.sidebar--collapsed {
width: 0;
min-width: 0;
padding: 0;
border-right: none;
overflow: hidden;
}
.sidebar--collapsed * {
visibility: hidden;
}
.sidebar--mobile-open,
.sidebar--mobile-closed {
position: fixed;
top: var(--shell-header-height, 56px);
left: 0;
bottom: 0;
width: min(280px, calc(100vw - 40px));
min-width: min(280px, calc(100vw - 40px));
z-index: 40;
box-shadow: 8px 0 24px rgba(15, 23, 42, 0.22);
}
.sidebar--mobile-open {
transform: translateX(0);
}
.sidebar--mobile-closed {
transform: translateX(-100%);
}
.sidebar-overlay {
position: fixed;
inset: 0;
z-index: 35;
background: rgba(15, 23, 42, 0.4);
}
.overlay-fade-enter-active,
.overlay-fade-leave-active {
transition: opacity 0.3s ease;
}
.overlay-fade-enter-from,
.overlay-fade-leave-to {
opacity: 0;
} }
.drawer + .drawer { .drawer + .drawer {
@@ -123,12 +219,33 @@ body {
font-weight: 600; font-weight: 600;
} }
.content { .shell-content {
background: #fff; flex: 1;
border: 1px solid #e5e7eb; min-width: 0;
border-radius: 10px; min-height: 0;
overflow-y: auto;
background: #f5f7fa;
padding: 16px; padding: 16px;
min-height: 70vh; }
.shell-content .resource-page,
.shell-content .dashboard,
.shell-content .qc-gate-page,
.shell-content .job-query-page,
.shell-content .excel-query-page,
.shell-content .query-tool-page,
.shell-content .tmtt-page,
.shell-content .tables-page {
max-width: none;
min-height: auto;
}
.shell-content .tables-page .container {
max-width: none;
}
.shell-content .resource-page {
padding: 0;
} }
.breadcrumb { .breadcrumb {
@@ -282,7 +399,7 @@ body {
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.24); box-shadow: 0 10px 30px rgba(15, 23, 42, 0.24);
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
padding: 14px; padding: 14px;
z-index: 30; z-index: 50;
} }
.health-popup h4 { .health-popup h4 {
@@ -363,25 +480,29 @@ body {
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.drawer-link, .drawer-link,
.route-fade-enter-active, .route-fade-enter-active,
.route-fade-leave-active { .route-fade-leave-active,
.sidebar,
.sidebar--mobile-open,
.sidebar--mobile-closed,
.sidebar-overlay,
.overlay-fade-enter-active,
.overlay-fade-leave-active,
.sidebar-toggle {
transition: none !important; transition: none !important;
} }
.dot.loading {
animation: none !important;
}
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.shell-main {
grid-template-columns: 1fr;
}
.shell-header { .shell-header {
flex-direction: column; padding: 12px 16px;
align-items: flex-start;
} }
.shell-header-right { .shell-header-right {
width: 100%; margin-left: auto;
flex-direction: column;
align-items: flex-start;
} }
.health-trigger { .health-trigger {
@@ -389,7 +510,7 @@ body {
justify-content: space-between; justify-content: space-between;
} }
.sidebar { .shell-content {
position: static; padding: 12px;
} }
} }

View File

@@ -1,6 +1,7 @@
<script setup> <script setup>
import { onMounted } from 'vue'; import { computed, onMounted } from 'vue';
import MultiSelect from '../resource-shared/components/MultiSelect.vue';
import FilterToolbar from '../shared-ui/components/FilterToolbar.vue'; import FilterToolbar from '../shared-ui/components/FilterToolbar.vue';
import SectionCard from '../shared-ui/components/SectionCard.vue'; import SectionCard from '../shared-ui/components/SectionCard.vue';
import { useQueryToolData } from './composables/useQueryToolData.js'; import { useQueryToolData } from './composables/useQueryToolData.js';
@@ -25,6 +26,20 @@ const {
exportCurrentCsv, exportCurrentCsv,
} = useQueryToolData(); } = useQueryToolData();
const equipmentOptions = computed(() =>
equipment.options.map((item) => ({
value: item.RESOURCEID,
label: item.RESOURCENAME || item.RESOURCEID,
})),
);
const workcenterGroupOptions = computed(() =>
batch.workcenterGroups.map((group) => ({
value: group.name || group,
label: group.name || group,
})),
);
function formatCell(value) { function formatCell(value) {
if (value === null || value === undefined || value === '') { if (value === null || value === undefined || value === '') {
return '-'; return '-';
@@ -65,11 +80,14 @@ onMounted(async () => {
</label> </label>
<label class="query-tool-filter"> <label class="query-tool-filter">
<span>站點群組</span> <span>站點群組</span>
<select v-model="batch.selectedWorkcenterGroups" multiple size="3"> <MultiSelect
<option v-for="group in batch.workcenterGroups" :key="group.name || group" :value="group.name || group"> :model-value="batch.selectedWorkcenterGroups"
{{ group.name || group }} :options="workcenterGroupOptions"
</option> :disabled="loading.bootstrapping"
</select> placeholder="全部群組"
searchable
@update:model-value="batch.selectedWorkcenterGroups = $event"
/>
</label> </label>
<template #actions> <template #actions>
<button type="button" class="query-tool-btn query-tool-btn-primary" :disabled="loading.resolving" @click="resolveLots"> <button type="button" class="query-tool-btn query-tool-btn-primary" :disabled="loading.resolving" @click="resolveLots">
@@ -178,11 +196,14 @@ onMounted(async () => {
<FilterToolbar> <FilterToolbar>
<label class="query-tool-filter"> <label class="query-tool-filter">
<span>設備複選</span> <span>設備複選</span>
<select v-model="equipment.selectedEquipmentIds" multiple size="4"> <MultiSelect
<option v-for="item in equipment.options" :key="item.RESOURCEID" :value="item.RESOURCEID"> :model-value="equipment.selectedEquipmentIds"
{{ item.RESOURCENAME || item.RESOURCEID }} :options="equipmentOptions"
</option> :disabled="loading.bootstrapping || loading.equipment"
</select> placeholder="全部設備"
searchable
@update:model-value="equipment.selectedEquipmentIds = $event"
/>
</label> </label>
<label class="query-tool-filter"> <label class="query-tool-filter">
<span>查詢類型</span> <span>查詢類型</span>

View File

@@ -4,9 +4,11 @@
@layer base { @layer base {
:root { :root {
--portal-shell-max-width: 1600px; --portal-shell-max-width: none;
--portal-shell-gap: 12px; --portal-shell-gap: 12px;
--portal-shell-bg: #f5f7fa; --portal-shell-bg: #f5f7fa;
--portal-sidebar-width: 240px;
--shell-header-height: 56px;
--portal-panel-bg: #ffffff; --portal-panel-bg: #ffffff;
--portal-text-primary: #1f2937; --portal-text-primary: #1f2937;
--portal-text-secondary: #64748b; --portal-text-secondary: #64748b;
@@ -61,7 +63,7 @@
} }
.u-content-shell { .u-content-shell {
max-width: var(--portal-shell-max-width); width: 100%;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }

View File

@@ -0,0 +1,92 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
SIDEBAR_STORAGE_KEY,
buildSidebarUiState,
parseSidebarCollapsedPreference,
serializeSidebarCollapsedPreference,
} from '../src/portal-shell/sidebarState.js';
function readSource(relativePath) {
return readFileSync(resolve(process.cwd(), relativePath), 'utf8');
}
test('buildSidebarUiState marks desktop collapse correctly', () => {
const expanded = buildSidebarUiState({
isMobile: false,
sidebarCollapsed: false,
sidebarMobileOpen: false,
});
assert.equal(expanded.sidebarClass['sidebar--collapsed'], false);
assert.equal(expanded.ariaExpanded, 'true');
const collapsed = buildSidebarUiState({
isMobile: false,
sidebarCollapsed: true,
sidebarMobileOpen: false,
});
assert.equal(collapsed.sidebarClass['sidebar--collapsed'], true);
assert.equal(collapsed.sidebarClass['sidebar--mobile-open'], false);
assert.equal(collapsed.ariaExpanded, 'false');
});
test('buildSidebarUiState marks mobile overlay states correctly', () => {
const closed = buildSidebarUiState({
isMobile: true,
sidebarCollapsed: true,
sidebarMobileOpen: false,
});
assert.equal(closed.sidebarClass['sidebar--mobile-closed'], true);
assert.equal(closed.sidebarClass['sidebar--collapsed'], false);
assert.equal(closed.ariaExpanded, 'false');
const open = buildSidebarUiState({
isMobile: true,
sidebarCollapsed: true,
sidebarMobileOpen: true,
});
assert.equal(open.sidebarClass['sidebar--mobile-open'], true);
assert.equal(open.sidebarClass['sidebar--mobile-closed'], false);
assert.equal(open.ariaExpanded, 'true');
});
test('sidebar collapsed preference serializes and parses as expected', () => {
assert.equal(serializeSidebarCollapsedPreference(true), 'true');
assert.equal(serializeSidebarCollapsedPreference(false), 'false');
assert.equal(parseSidebarCollapsedPreference('true'), true);
assert.equal(parseSidebarCollapsedPreference('false'), false);
assert.equal(parseSidebarCollapsedPreference(null), false);
});
test('portal shell template uses shell-content and overlay close wiring', () => {
const source = readSource('src/portal-shell/App.vue');
assert.match(source, /<section class="shell-content">/);
assert.doesNotMatch(source, /<section class="content">/);
assert.match(source, /class="sidebar-overlay"/);
assert.match(source, /@click="closeMobileSidebar"/);
assert.match(source, /event\.key === 'Escape'/);
assert.match(source, /SIDEBAR_STORAGE_KEY/);
});
test('toggleSidebar keeps desktop collapse and persistence wiring', () => {
const source = readSource('src/portal-shell/App.vue');
assert.match(source, /function toggleSidebar\(\)/);
assert.match(source, /sidebarCollapsed\.value = !sidebarCollapsed\.value/);
assert.match(source, /persistSidebarPreference\(\)/);
});
test('sessionStorage preference restore wiring exists', () => {
const source = readSource('src/portal-shell/App.vue');
assert.match(source, /function restoreSidebarPreference\(\)/);
assert.match(source, /window\.sessionStorage\.getItem\(SIDEBAR_STORAGE_KEY\)/);
});
test('sidebar sessionStorage key remains stable', () => {
assert.equal(SIDEBAR_STORAGE_KEY, 'portal-shell:sidebar-collapsed');
});

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-11

View File

@@ -0,0 +1,89 @@
## Context
The portal shell (`frontend/src/portal-shell/App.vue` + `frontend/src/portal-shell/style.css`) currently uses a block-centered layout: `.shell` has `max-width: 1600px; margin: 0 auto; padding: 20px`, `.shell-main` uses `display: grid; grid-template-columns: 220px minmax(0,1fr)`, and both `.sidebar` and `.content` are styled as bordered, rounded white cards. Page modules add their own `max-width` (1680-1900px) and `padding`.
The result is a segmented, boxed appearance that wastes screen real estate. The migration to full-screen fluid layout with a collapsible drawer addresses this.
**Constraints:**
- No new dependencies (pure Vue 3 + Tailwind CSS 3 + vanilla CSS)
- Sidebar navigation items are text-only (no icons) — icon-only collapsed mode is not viable
- Pages must still render correctly standalone (outside the portal shell) for development
- Existing color scheme and navigation hierarchy must be preserved
## Goals / Non-Goals
**Goals:**
- Full-viewport fluid layout with no max-width constraints at shell or page level
- Collapsible sidebar: push-mode on desktop (content resizes), overlay-mode on mobile
- Smooth 300ms transitions for sidebar open/close
- Sidebar state persistence within browser session
- Keyboard accessibility (Escape closes mobile drawer)
- Maintain all existing navigation, routing, and page functionality
**Non-Goals:**
- Icon-only collapsed sidebar mode (nav items have no icons; complete hide is chosen)
- Redesigning individual page components or card layouts within pages (deferred to Phase 2)
- Adding new dependencies (headlessui, shadcn, etc.)
- Server-side changes or API modifications
- Full ARIA focus-trap for mobile overlay (simple Escape + backdrop click suffices)
- Verifying pages not registered in shell route contracts (e.g. routes with missing contract warnings)
## Decisions
### D1: Flexbox over CSS Grid for `.shell-main`
**Choice**: Replace `display: grid; grid-template-columns: 220px minmax(0,1fr)` with `display: flex`.
**Rationale**: CSS Grid `grid-template-columns` cannot be smoothly animated with CSS transitions. Flexbox with `width` + `min-width` transitions on the sidebar provides smooth animated collapse/expand. The layout is a simple two-column split, which flexbox handles naturally.
### D2: Sidebar collapses to 0 width (complete hide)
**Choice**: Desktop collapsed state sets sidebar `width: 0; min-width: 0; overflow: hidden`.
**Rationale**: The current navigation items are text-only with no icons. An icon-only strip would require adding icons to every nav item — a separate design effort. Complete hide is the simplest approach that provides maximum content space.
### D3: Mobile overlay vs desktop push
**Choice**: Desktop uses push mode (content resizes via flex). Mobile (<=900px) uses fixed-position overlay with backdrop.
**Rationale**: On desktop, push mode provides a stable layout without content obscuring. On mobile, the viewport is too narrow for push mode — overlay maximizes both sidebar and content usability. The 900px breakpoint matches the existing responsive threshold.
### D4: JavaScript viewport detection instead of pure CSS media queries
**Choice**: Use a `resize` event listener to set `isMobile` ref, then apply CSS classes based on state.
**Rationale**: The sidebar has three distinct behaviors: desktop-expanded, desktop-collapsed, and mobile-overlay. Pure CSS media queries cannot differentiate between "desktop-collapsed" and "mobile-hidden" since both have zero sidebar width. JavaScript state allows clean separation of desktop collapse (user choice) and mobile overlay (viewport-driven).
### D5: `sessionStorage` for sidebar preference
**Choice**: Persist collapsed/expanded state in `sessionStorage` (not `localStorage`).
**Rationale**: Session-scoped persistence means the user's choice persists across page navigations and refreshes within the same tab, but new tabs start with sidebar expanded. This matches the ephemeral nature of a UI layout preference. The codebase already uses `sessionStorage` for recovery keys in `NativeRouteView.vue`.
### D6: Page-level max-width override via scoped selectors
**Choice**: Add `.shell-content .xxx-page { max-width: none; }` rules in `frontend/src/portal-shell/style.css` rather than modifying each page's CSS. Include `.shell-content .tables-page .container` for the tables module where `max-width` is on the inner `.container` element, not the page wrapper.
**Rationale**: This keeps all shell-level layout overrides in one file, preserves standalone page rendering (pages still have their own max-width when accessed directly), and avoids touching 10+ page CSS files.
### D7: Content area background color
**Choice**: Change `.shell-content` background from `#ffffff` to `#f5f7fa` (app background color).
**Rationale**: With borders and border-radius removed, the content area merges visually with the page background. Using the app bg color instead of white allows individual page cards (`.section-card`, `.header-gradient`) to stand out on their own. This maintains the card-on-background visual hierarchy.
### D8: Rename `.content` to `.shell-content` to prevent CSS collision
**Choice**: Rename the shell's main content area class from `.content` to `.shell-content` in both the template and CSS.
**Rationale**: The tables module (`tables/App.vue:81`) and potentially other page modules also use a `.content` class. If the shell rewrites `.content` with `flex: 1; overflow-y: auto`, it will leak into page-level `.content` elements and cause layout breakage. Using `.shell-content` scopes the styles unambiguously to the shell layer. This is a low-cost rename (one template attribute + CSS find-and-replace in a single file) with high defensive value.
## Risks / Trade-offs
- **[Text wrapping during animation]** → Sidebar text may wrap awkwardly during 300ms width transition. Mitigate with `white-space: nowrap; overflow: hidden` on the sidebar.
- **[Double scrollbar]** → Content area gets `overflow-y: auto`, but some pages have `min-height: 100vh`. Mitigate with `.shell-content .resource-page { min-height: auto }` overrides.
- **[Residual gutter after collapse]** → If legacy `.shell-main` `gap: 12px` is left in place during flex migration, collapsed sidebar still leaves visible empty space. Mitigate by removing `gap` from `.shell-main`.
- **[Health popup z-index clash]** → `.health-popup` uses `z-index: 30` which is below mobile sidebar overlay `z-index: 40`. Mitigate by bumping health popup to `z-index: 50`.
- **[Wide content readability]** → Removing all max-width means tables and cards stretch on ultra-wide monitors. Accepted trade-off per user preference. Individual page teams can re-add max-width later if needed.
- **[sessionStorage loss]** → If user clears session data, sidebar preference resets. Acceptable — sidebar defaults to expanded which is a safe fallback.
- **[CSS class collision]** → Shell `.content` class collides with page-level `.content` (e.g. tables module). Mitigate by renaming to `.shell-content` (Decision D8).

View File

@@ -0,0 +1,35 @@
## Why
The portal shell just completed an iframe-to-Vue migration but still uses a block/card-based layout with `max-width: 1600px` centered shell, fixed `220px` sidebar, and rounded-corner card containers. This creates a segmented "boxed" appearance with wasted screen real estate and occasional card overflow issues. Converting to a full-screen fluid layout with a collapsible sidebar drawer maximizes usable space and provides a modern dashboard experience.
> **Scope**: This change is **Portal Shell Modernize Phase 1** — it targets the shell layout layer only (header, sidebar, content wrapper). Page-level component redesign (cards, tables, charts within individual pages) is out of scope and should be addressed in a separate Phase 2 change.
## What Changes
- **BREAKING**: Remove `max-width` constraints from the portal shell (`.shell` 1600px) and all page-level wrappers (`.dashboard` 1800px, `.qc-gate-page` 1900px, `.job-query-page` 1680px, etc.) — content fills available viewport width
- Remove border-radius and border from the shell header, sidebar, and content area — edge-to-edge fluid appearance
- Convert `.shell-main` from CSS Grid (`220px | 1fr`) to Flexbox with animated sidebar width transitions
- Add collapsible sidebar: desktop push-mode (width animates from 240px to 0), mobile overlay-mode (slide-in drawer with backdrop)
- Add hamburger toggle button in the shell header
- Add sidebar state persistence via `sessionStorage`
- Add mobile overlay backdrop with fade transition
- Add keyboard accessibility (Escape to close mobile drawer)
- Maintain existing gradient color scheme, navigation hierarchy, card-level styling within pages
## Capabilities
### New Capabilities
- `collapsible-sidebar-drawer`: Sidebar collapse/expand behavior, toggle button, mobile overlay, state persistence, keyboard accessibility, smooth transitions
### Modified Capabilities
- `spa-shell-navigation`: Shell layout changes from block-centered grid to full-screen fluid flexbox; sidebar becomes collapsible; content area becomes scrollable flex child
- `tailwind-design-system`: CSS variables updated (`--portal-shell-max-width` removed), `.u-content-shell` utility changed from max-width to full-width
## Impact
- **Portal shell** (`frontend/src/portal-shell/App.vue`, `frontend/src/portal-shell/style.css`): Major layout restructure — template adds toggle button, overlay, class bindings; CSS rewrites `.shell`, `.shell-main`, `.sidebar`; `.content` renamed to `.shell-content` to avoid class collision with page-level `.content` (e.g. in tables module)
- **Global styles** (`frontend/src/styles/tailwind.css`): CSS variable updates, utility class changes
- **Shell-registered page modules only**: Page-level max-width and padding overridden when embedded in shell via `.shell-content .xxx-page` selectors (standalone rendering unaffected). Verification scope limited to pages registered in shell route contracts — unregistered routes (e.g. missing contract warnings) are excluded
- **Health popup**: z-index adjustment needed to stay above new sidebar overlay layer
- **No new dependencies**: Pure Vue + Tailwind + CSS, no additional libraries
- **New test file**: `frontend/tests/portal-shell-sidebar.test.js` — automated tests (Node `node:test` harness) for sidebar collapse/expand, mobile overlay, and sessionStorage persistence

View File

@@ -0,0 +1,67 @@
## ADDED Requirements
### Requirement: Sidebar SHALL be collapsible via a toggle button
The portal shell SHALL provide a toggle button in the header that collapses and expands the sidebar. On desktop viewports (>900px), collapsing SHALL animate the sidebar width from 240px to 0px using push mode, causing the content area to resize and fill the freed space. Expanding SHALL reverse the animation.
#### Scenario: Desktop sidebar collapse
- **WHEN** a user clicks the sidebar toggle button on a desktop viewport
- **AND** the sidebar is currently expanded
- **THEN** the sidebar width SHALL animate to 0px over 300ms
- **THEN** the content area SHALL expand to fill the full viewport width
#### Scenario: Desktop sidebar expand
- **WHEN** a user clicks the sidebar toggle button on a desktop viewport
- **AND** the sidebar is currently collapsed
- **THEN** the sidebar width SHALL animate to 240px over 300ms
- **THEN** the content area SHALL shrink to accommodate the sidebar
### Requirement: Mobile sidebar SHALL use overlay drawer mode
On mobile viewports (<=900px), the sidebar SHALL behave as a fixed-position overlay drawer that slides in from the left, with a semi-transparent backdrop covering the content area.
#### Scenario: Mobile sidebar open
- **WHEN** a user taps the toggle button on a mobile viewport
- **AND** the sidebar is currently hidden
- **THEN** the sidebar SHALL slide in from the left as a fixed overlay (280px width)
- **THEN** a semi-transparent backdrop SHALL appear behind the sidebar and above the content
#### Scenario: Mobile sidebar close via backdrop
- **WHEN** a user taps the backdrop overlay
- **THEN** the sidebar SHALL slide out to the left
- **THEN** the backdrop SHALL fade out
#### Scenario: Mobile sidebar close via Escape key
- **WHEN** the mobile sidebar overlay is open
- **AND** a user presses the Escape key
- **THEN** the sidebar SHALL close
#### Scenario: Mobile sidebar closes on navigation
- **WHEN** the mobile sidebar overlay is open
- **AND** a user clicks a navigation link
- **THEN** the sidebar SHALL automatically close after the route change
### Requirement: Sidebar state SHALL persist within the browser session
The collapsed/expanded state of the desktop sidebar SHALL be persisted to `sessionStorage` so that the preference survives page refreshes within the same browser tab.
#### Scenario: State persistence on refresh
- **WHEN** a user collapses the sidebar and refreshes the page
- **THEN** the sidebar SHALL remain collapsed after reload
#### Scenario: New tab starts expanded
- **WHEN** a user opens the portal in a new browser tab
- **THEN** the sidebar SHALL default to expanded regardless of other tabs' state
### Requirement: Sidebar transitions SHALL respect reduced-motion preference
All sidebar transitions (width animation, overlay slide, backdrop fade) SHALL be disabled when the user has `prefers-reduced-motion: reduce` enabled.
#### Scenario: Reduced motion disables sidebar animation
- **WHEN** the user's OS or browser has reduced-motion enabled
- **AND** the user toggles the sidebar
- **THEN** the sidebar state SHALL change instantly without animation
### Requirement: Toggle button SHALL be accessible
The sidebar toggle button SHALL include `aria-label` and `aria-expanded` attributes for screen reader accessibility.
#### Scenario: Screen reader announces toggle state
- **WHEN** a screen reader user focuses the toggle button
- **THEN** the button SHALL announce its current expanded/collapsed state via `aria-expanded`
- **THEN** the button SHALL have an `aria-label` describing its purpose

View File

@@ -0,0 +1,26 @@
## MODIFIED Requirements
### Requirement: Portal SHALL provide a SPA shell driven by Vue Router
The portal frontend SHALL use a single SPA shell entry and Vue Router to render page modules without iframe embedding, and SHALL route each page through native route-view integration. The shell layout SHALL use a full-viewport fluid layout with flexbox, removing all max-width constraints and block-centered styling. The main content area (`.shell-content`) SHALL fill available space as a flex child, and the sidebar SHALL be a collapsible flex child that pushes content when expanded on desktop. The content area class SHALL be `.shell-content` (not `.content`) to avoid CSS collision with page-level `.content` classes.
#### Scenario: Drawer navigation renders integrated route view
- **WHEN** a user clicks a sidebar page entry whose migration mode is `native`
- **THEN** the active route SHALL be updated through Vue Router
- **THEN** the main content area SHALL render the corresponding page module inside shell route-view without iframe usage
- **THEN** the content area SHALL fill the available viewport width minus the sidebar width (if sidebar is expanded)
#### Scenario: Shell layout fills full viewport
- **WHEN** the portal shell renders
- **THEN** the shell SHALL span the full viewport width with no max-width constraint
- **THEN** the header SHALL span edge-to-edge with no border-radius
- **THEN** the sidebar and content area SHALL have no outer borders or border-radius
#### Scenario: Page-level max-width constraints are removed when embedded
- **WHEN** a page module registered in the shell route contracts renders inside `.shell-content`
- **THEN** page-level max-width constraints SHALL be overridden to allow full-width rendering
- **THEN** page-level duplicate padding SHALL be removed to avoid double spacing
- **THEN** standalone page rendering (outside the shell) SHALL remain unaffected
#### Scenario: Shell content class avoids collision with page-level classes
- **WHEN** a page module that uses its own `.content` class renders inside the shell
- **THEN** the shell's content wrapper (`.shell-content`) SHALL NOT interfere with the page's `.content` styling

View File

@@ -0,0 +1,15 @@
## MODIFIED Requirements
### Requirement: Frontend styles SHALL be governed by Tailwind design tokens
The frontend SHALL define a Tailwind-based design token system for color, spacing, typography, radius, and elevation to ensure consistent styling across modules. The `--portal-shell-max-width` CSS variable SHALL be set to `none` to support the fluid layout. A `--portal-sidebar-width` variable SHALL be added for sidebar width reference. The `.u-content-shell` utility class SHALL use `width: 100%` instead of `max-width` constraint.
#### Scenario: Shared token usage across modules
- **WHEN** two report modules render equivalent UI elements (e.g., card, filter chip, primary button)
- **THEN** they SHALL use the same token-backed style semantics
- **THEN** visual output SHALL remain consistent across modules
#### Scenario: Fluid layout tokens
- **WHEN** the portal shell renders
- **THEN** `--portal-shell-max-width` SHALL resolve to `none`
- **THEN** `--portal-sidebar-width` SHALL resolve to `240px`
- **THEN** `.u-content-shell` SHALL apply `width: 100%` without max-width constraint

View File

@@ -0,0 +1,69 @@
## 1. Sidebar State Management (App.vue script)
- [x] 1.1 Add `sidebarCollapsed`, `sidebarMobileOpen`, `isMobile` refs to `App.vue` `<script setup>`
- [x] 1.2 Implement `toggleSidebar()`, `closeMobileSidebar()`, `checkViewport()` functions
- [x] 1.3 Add `sessionStorage` load/save for sidebar collapsed preference (`portal-shell:sidebar-collapsed`)
- [x] 1.4 Register `resize` listener in `onMounted`, clean up in `onUnmounted`
- [x] 1.5 Add Escape key handler to close mobile sidebar overlay
- [x] 1.6 Add `closeMobileSidebar()` call to existing route watcher for auto-close on navigation
## 2. Template Restructure (App.vue template)
- [x] 2.1 Add `.shell-header-left` wrapper with hamburger toggle `<button>` (inline SVG, `aria-label`, `aria-expanded`)
- [x] 2.2 Add `:class` binding on `.shell` root: `{ 'sidebar-collapsed': sidebarCollapsed && !isMobile }`
- [x] 2.3 Add `:class` bindings on `<aside class="sidebar">`: `sidebar--collapsed`, `sidebar--mobile-open`, `sidebar--mobile-closed`
- [x] 2.4 Add `<Transition name="overlay-fade">` wrapped `<div class="sidebar-overlay">` before `<main>`, shown when `isMobile && sidebarMobileOpen`
- [x] 2.5 Rename `<section class="content">` to `<section class="shell-content">` in the template
## 3. Shell CSS Rewrite (`frontend/src/portal-shell/style.css`)
- [x] 3.1 Rename all `.content` selectors to `.shell-content` throughout `style.css` (`.content`, `.content .xxx-page`, etc.)
- [x] 3.2 `.shell`: remove `max-width: 1600px`, `padding: 20px`, `margin: 0 auto`; add `display: flex; flex-direction: column`
- [x] 3.3 `.shell-header`: remove `border-radius: 12px`; adjust `padding` to `12px 20px`; add `flex-shrink: 0`
- [x] 3.4 Add `.shell-header-left` styles (flex, align-items center, gap 12px)
- [x] 3.5 Add `.sidebar-toggle` button styles (36x36, border/bg rgba white, hover, focus-visible outline)
- [x] 3.6 `.shell-main`: replace `display: grid; grid-template-columns` with `display: flex; flex: 1; overflow: hidden`; remove legacy `gap: 12px` to prevent residual gutter when sidebar collapses
- [x] 3.7 `.sidebar`: rewrite to `width: 240px; min-width: 240px; border-right; overflow-y: auto; flex-shrink: 0; transition: width/min-width/padding 0.3s; white-space: nowrap` — remove border-radius, border, sticky, height: fit-content
- [x] 3.8 Add `.sidebar--collapsed` styles: `width: 0; min-width: 0; padding: 0; border-right: none; overflow: hidden`
- [x] 3.9 Add `.sidebar--mobile-closed` styles: fixed position, `transform: translateX(-100%)`, 280px width, z-index 40
- [x] 3.10 Add `.sidebar--mobile-open` styles: fixed position, `transform: translateX(0)`, box-shadow, z-index 40
- [x] 3.11 Add `.sidebar-overlay` styles: fixed inset 0, z-index 35, `background: rgba(0,0,0,0.4)`
- [x] 3.12 Add `overlay-fade` transition classes (enter/leave opacity 0.3s)
- [x] 3.13 `.shell-content`: remove `border`, `border-radius: 10px`, `min-height: 70vh`; add `flex: 1; min-width: 0; overflow-y: auto`; change background to `#f5f7fa`
- [x] 3.14 Bump `.health-popup` z-index from `30` to `50`
## 4. Page-Level Overrides (`frontend/src/portal-shell/style.css`)
- [x] 4.1 Add `.shell-content .resource-page, .shell-content .dashboard, .shell-content .qc-gate-page, .shell-content .job-query-page, .shell-content .excel-query-page, .shell-content .query-tool-page, .shell-content .tmtt-page, .shell-content .tables-page` override: `max-width: none; min-height: auto`
- [x] 4.2 Add `.shell-content .tables-page .container` override: `max-width: none` (tables module has max-width on inner `.container`, not on page wrapper)
- [x] 4.3 Add `.shell-content .resource-page` override: `padding: 0` (remove duplicate padding)
## 5. Responsive and Accessibility (`frontend/src/portal-shell/style.css`)
- [x] 5.1 Simplify `@media (max-width: 900px)` block — remove grid/sidebar rules (now JS-driven); keep header/content padding adjustments only
- [x] 5.2 Extend `@media (prefers-reduced-motion: reduce)` to include `.sidebar`, `.sidebar--mobile-*`, `.sidebar-overlay`, `.overlay-fade-*`, `.sidebar-toggle`
## 6. CSS Variables Update (`frontend/src/styles/tailwind.css`)
- [x] 6.1 Change `--portal-shell-max-width` from `1600px` to `none`
- [x] 6.2 Add `--portal-sidebar-width: 240px` and `--shell-header-height: 56px`
- [x] 6.3 Update `.u-content-shell` utility: replace `max-width` with `width: 100%`
## 7. Automated Tests
- [x] 7.1 Create `frontend/tests/portal-shell-sidebar.test.js` using existing Node `node:test` harness
- [x] 7.2 Test: desktop sidebar collapse — toggle sets `sidebarCollapsed` to true, sidebar gets `sidebar--collapsed` class
- [x] 7.3 Test: mobile overlay close via backdrop — clicking overlay calls `closeMobileSidebar()`, `sidebarMobileOpen` becomes false
- [x] 7.4 Test: mobile overlay close via Escape — pressing Escape when overlay open closes sidebar
- [x] 7.5 Test: sessionStorage persistence — collapsing sidebar writes to sessionStorage; mounting with stored value restores collapsed state
## 8. Manual Verification
- [x] 8.1 Run `npm run build` — confirm no build errors
- [x] 8.2 Test desktop: sidebar expanded → toggle collapse → toggle expand (smooth 300ms animation)
- [x] 8.3 Test mobile (<= 900px): toggle opens overlay drawer with backdrop → tap backdrop closes → Escape closes
- [x] 8.4 Test route navigation auto-closes mobile sidebar
- [x] 8.5 Test health popup z-index: open health popup while mobile sidebar is open → popup stays on top
- [x] 8.6 Test shell-registered page modules render fluid (no max-width centering) within the shell
- [x] 8.7 Test `prefers-reduced-motion`: all transitions disabled
- [x] 8.8 Verify tables module `.content` class is not affected by shell `.shell-content` styles

View File

@@ -0,0 +1,70 @@
## Purpose
Define stable requirements for collapsible-sidebar-drawer.
## Requirements
### Requirement: Sidebar SHALL be collapsible via a toggle button
The portal shell SHALL provide a toggle button in the header that collapses and expands the sidebar. On desktop viewports (>900px), collapsing SHALL animate the sidebar width from 240px to 0px using push mode, causing the content area to resize and fill the freed space. Expanding SHALL reverse the animation.
#### Scenario: Desktop sidebar collapse
- **WHEN** a user clicks the sidebar toggle button on a desktop viewport
- **AND** the sidebar is currently expanded
- **THEN** the sidebar width SHALL animate to 0px over 300ms
- **THEN** the content area SHALL expand to fill the full viewport width
#### Scenario: Desktop sidebar expand
- **WHEN** a user clicks the sidebar toggle button on a desktop viewport
- **AND** the sidebar is currently collapsed
- **THEN** the sidebar width SHALL animate to 240px over 300ms
- **THEN** the content area SHALL shrink to accommodate the sidebar
### Requirement: Mobile sidebar SHALL use overlay drawer mode
On mobile viewports (<=900px), the sidebar SHALL behave as a fixed-position overlay drawer that slides in from the left, with a semi-transparent backdrop covering the content area.
#### Scenario: Mobile sidebar open
- **WHEN** a user taps the toggle button on a mobile viewport
- **AND** the sidebar is currently hidden
- **THEN** the sidebar SHALL slide in from the left as a fixed overlay (280px width)
- **THEN** a semi-transparent backdrop SHALL appear behind the sidebar and above the content
#### Scenario: Mobile sidebar close via backdrop
- **WHEN** a user taps the backdrop overlay
- **THEN** the sidebar SHALL slide out to the left
- **THEN** the backdrop SHALL fade out
#### Scenario: Mobile sidebar close via Escape key
- **WHEN** the mobile sidebar overlay is open
- **AND** a user presses the Escape key
- **THEN** the sidebar SHALL close
#### Scenario: Mobile sidebar closes on navigation
- **WHEN** the mobile sidebar overlay is open
- **AND** a user clicks a navigation link
- **THEN** the sidebar SHALL automatically close after the route change
### Requirement: Sidebar state SHALL persist within the browser session
The collapsed/expanded state of the desktop sidebar SHALL be persisted to `sessionStorage` so that the preference survives page refreshes within the same browser tab.
#### Scenario: State persistence on refresh
- **WHEN** a user collapses the sidebar and refreshes the page
- **THEN** the sidebar SHALL remain collapsed after reload
#### Scenario: New tab starts expanded
- **WHEN** a user opens the portal in a new browser tab
- **THEN** the sidebar SHALL default to expanded regardless of other tabs' state
### Requirement: Sidebar transitions SHALL respect reduced-motion preference
All sidebar transitions (width animation, overlay slide, backdrop fade) SHALL be disabled when the user has `prefers-reduced-motion: reduce` enabled.
#### Scenario: Reduced motion disables sidebar animation
- **WHEN** the user's OS or browser has reduced-motion enabled
- **AND** the user toggles the sidebar
- **THEN** the sidebar state SHALL change instantly without animation
### Requirement: Toggle button SHALL be accessible
The sidebar toggle button SHALL include `aria-label` and `aria-expanded` attributes for screen reader accessibility.
#### Scenario: Screen reader announces toggle state
- **WHEN** a screen reader user focuses the toggle button
- **THEN** the button SHALL announce its current expanded/collapsed state via `aria-expanded`
- **THEN** the button SHALL have an `aria-label` describing its purpose

View File

@@ -2,12 +2,29 @@
Define stable requirements for spa-shell-navigation. Define stable requirements for spa-shell-navigation.
## Requirements ## Requirements
### Requirement: Portal SHALL provide a SPA shell driven by Vue Router ### Requirement: Portal SHALL provide a SPA shell driven by Vue Router
The portal frontend SHALL use a single SPA shell entry and Vue Router to render page modules without iframe embedding, and SHALL route each page through either native route-view integration or a temporary wrapper component. The portal frontend SHALL use a single SPA shell entry and Vue Router to render page modules without iframe embedding, and SHALL route each page through native route-view integration. The shell layout SHALL use a full-viewport fluid layout with flexbox, removing all max-width constraints and block-centered styling. The main content area (`.shell-content`) SHALL fill available space as a flex child, and the sidebar SHALL be a collapsible flex child that pushes content when expanded on desktop. The content area class SHALL be `.shell-content` (not `.content`) to avoid CSS collision with page-level `.content` classes.
#### Scenario: Drawer navigation renders integrated route view #### Scenario: Drawer navigation renders integrated route view
- **WHEN** a user clicks a sidebar page entry whose migration mode is `native` - **WHEN** a user clicks a sidebar page entry whose migration mode is `native`
- **THEN** the active route SHALL be updated through Vue Router - **THEN** the active route SHALL be updated through Vue Router
- **THEN** the main content area SHALL render the corresponding page module inside shell route-view without iframe usage - **THEN** the main content area SHALL render the corresponding page module inside shell route-view without iframe usage
- **THEN** the content area SHALL fill the available viewport width minus the sidebar width (if sidebar is expanded)
#### Scenario: Shell layout fills full viewport
- **WHEN** the portal shell renders
- **THEN** the shell SHALL span the full viewport width with no max-width constraint
- **THEN** the header SHALL span edge-to-edge with no border-radius
- **THEN** the sidebar and content area SHALL have no outer borders or border-radius
#### Scenario: Page-level max-width constraints are removed when embedded
- **WHEN** a page module registered in the shell route contracts renders inside `.shell-content`
- **THEN** page-level max-width constraints SHALL be overridden to allow full-width rendering
- **THEN** page-level duplicate padding SHALL be removed to avoid double spacing
- **THEN** standalone page rendering (outside the shell) SHALL remain unaffected
#### Scenario: Shell content class avoids collision with page-level classes
- **WHEN** a page module that uses its own `.content` class renders inside the shell
- **THEN** the shell's content wrapper (`.shell-content`) SHALL NOT interfere with the page's `.content` styling
#### Scenario: Wrapper route remains available during migration #### Scenario: Wrapper route remains available during migration
- **WHEN** a user clicks a sidebar page entry whose migration mode is `wrapper` - **WHEN** a user clicks a sidebar page entry whose migration mode is `wrapper`

View File

@@ -4,13 +4,19 @@ Define stable requirements for tailwind-design-system.
## Requirements ## Requirements
### Requirement: Frontend styles SHALL be governed by Tailwind design tokens ### Requirement: Frontend styles SHALL be governed by Tailwind design tokens
The frontend SHALL define a Tailwind-based design token system for color, spacing, typography, radius, and elevation to ensure consistent styling across modules. The frontend SHALL define a Tailwind-based design token system for color, spacing, typography, radius, and elevation to ensure consistent styling across modules. The `--portal-shell-max-width` CSS variable SHALL be set to `none` to support the fluid layout. A `--portal-sidebar-width` variable SHALL be added for sidebar width reference. The `.u-content-shell` utility class SHALL use `width: 100%` instead of `max-width` constraint.
#### Scenario: Shared token usage across modules #### Scenario: Shared token usage across modules
- **WHEN** two report modules render equivalent UI elements (e.g., card, filter chip, primary button) - **WHEN** two report modules render equivalent UI elements (e.g., card, filter chip, primary button)
- **THEN** they SHALL use the same token-backed style semantics - **THEN** they SHALL use the same token-backed style semantics
- **THEN** visual output SHALL remain consistent across modules - **THEN** visual output SHALL remain consistent across modules
#### Scenario: Fluid layout tokens
- **WHEN** the portal shell renders
- **THEN** `--portal-shell-max-width` SHALL resolve to `none`
- **THEN** `--portal-sidebar-width` SHALL resolve to `240px`
- **THEN** `.u-content-shell` SHALL apply `width: 100%` without max-width constraint
### Requirement: Tailwind migration SHALL support coexistence with legacy CSS ### Requirement: Tailwind migration SHALL support coexistence with legacy CSS
The migration SHALL allow Tailwind and existing page CSS to coexist during phased rollout without breaking existing pages. The migration SHALL allow Tailwind and existing page CSS to coexist during phased rollout without breaking existing pages.