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>
import { computed, onMounted, ref, watch } from 'vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import HealthStatus from './components/HealthStatus.vue';
import { consumeNavigationNotice, syncNavigationRoutes } from './router.js';
import { normalizeRoutePath } from './routeContracts.js';
import {
SIDEBAR_STORAGE_KEY,
buildSidebarUiState,
isMobileViewport,
parseSidebarCollapsedPreference,
serializeSidebarCollapsedPreference,
} from './sidebarState.js';
const route = useRoute();
const router = useRouter();
@@ -20,6 +27,9 @@ const adminLinks = ref({
pages: '/admin/pages',
performance: '/admin/performance',
});
const sidebarCollapsed = ref(false);
const sidebarMobileOpen = ref(false);
const isMobile = ref(false);
function toShellPath(targetRoute) {
return normalizeRoutePath(targetRoute);
@@ -46,6 +56,71 @@ const adminLoginHref = computed(() => {
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() {
loading.value = true;
errorMessage.value = '';
@@ -111,12 +186,22 @@ async function loadNavigation() {
}
onMounted(() => {
restoreSidebarPreference();
checkViewport();
window.addEventListener('resize', handleViewportResize, { passive: true });
window.addEventListener('keydown', handleGlobalKeydown);
void loadNavigation();
});
onUnmounted(() => {
window.removeEventListener('resize', handleViewportResize);
window.removeEventListener('keydown', handleGlobalKeydown);
});
watch(
() => route.fullPath,
() => {
closeMobileSidebar();
navigationNotice.value = consumeNavigationNotice();
},
{ immediate: true },
@@ -124,9 +209,27 @@ watch(
</script>
<template>
<div class="shell">
<div class="shell" :class="sidebarUiState.shellClass">
<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>
</div>
<div class="shell-header-right">
@@ -144,8 +247,12 @@ watch(
</div>
</header>
<Transition name="overlay-fade">
<div v-if="isMobile && sidebarMobileOpen" class="sidebar-overlay" @click="closeMobileSidebar" />
</Transition>
<main class="shell-main">
<aside class="sidebar">
<aside class="sidebar" :class="sidebarUiState.sidebarClass">
<div v-if="loading" class="muted">載入導覽中...</div>
<div v-else-if="errorMessage" class="error">{{ errorMessage }}</div>
<template v-else>
@@ -164,7 +271,7 @@ watch(
</template>
</aside>
<section class="content">
<section class="shell-content">
<div v-if="navigationNotice" class="notice-banner">{{ navigationNotice }}</div>
<div class="breadcrumb">
<span v-if="breadcrumb.drawerName">{{ breadcrumb.drawerName }}</span>

View File

@@ -56,7 +56,7 @@ const NATIVE_MODULE_LOADERS = Object.freeze({
),
'/query-tool': createNativeLoader(
() => import('../query-tool/App.vue'),
[() => import('../query-tool/style.css')],
[() => import('../resource-shared/styles.css'), () => import('../query-tool/style.css')],
),
'/tmtt-defect': createNativeLoader(
() => 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 {
min-height: 100vh;
padding: 20px;
max-width: 1600px;
margin: 0 auto;
display: flex;
flex-direction: column;
}
.shell-header {
@@ -27,8 +26,15 @@ body {
gap: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border-radius: 12px;
padding: 20px;
padding: 12px 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 {
@@ -47,6 +53,39 @@ body {
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 {
display: inline-flex;
align-items: center;
@@ -73,20 +112,77 @@ body {
}
.shell-main {
margin-top: 14px;
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 12px;
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
}
.sidebar {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
width: var(--portal-sidebar-width, 240px);
min-width: var(--portal-sidebar-width, 240px);
border-right: 1px solid #e5e7eb;
padding: 10px;
height: fit-content;
position: sticky;
top: 20px;
overflow-y: auto;
overflow-x: hidden;
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 {
@@ -123,12 +219,33 @@ body {
font-weight: 600;
}
.content {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
.shell-content {
flex: 1;
min-width: 0;
min-height: 0;
overflow-y: auto;
background: #f5f7fa;
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 {
@@ -282,7 +399,7 @@ body {
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.24);
border: 1px solid #e2e8f0;
padding: 14px;
z-index: 30;
z-index: 50;
}
.health-popup h4 {
@@ -363,25 +480,29 @@ body {
@media (prefers-reduced-motion: reduce) {
.drawer-link,
.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;
}
.dot.loading {
animation: none !important;
}
}
@media (max-width: 900px) {
.shell-main {
grid-template-columns: 1fr;
}
.shell-header {
flex-direction: column;
align-items: flex-start;
padding: 12px 16px;
}
.shell-header-right {
width: 100%;
flex-direction: column;
align-items: flex-start;
margin-left: auto;
}
.health-trigger {
@@ -389,7 +510,7 @@ body {
justify-content: space-between;
}
.sidebar {
position: static;
.shell-content {
padding: 12px;
}
}

View File

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

View File

@@ -4,9 +4,11 @@
@layer base {
:root {
--portal-shell-max-width: 1600px;
--portal-shell-max-width: none;
--portal-shell-gap: 12px;
--portal-shell-bg: #f5f7fa;
--portal-sidebar-width: 240px;
--shell-header-height: 56px;
--portal-panel-bg: #ffffff;
--portal-text-primary: #1f2937;
--portal-text-secondary: #64748b;
@@ -61,7 +63,7 @@
}
.u-content-shell {
max-width: var(--portal-shell-max-width);
width: 100%;
margin-left: 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.
## 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 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
- **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
#### Scenario: Wrapper route remains available during migration
- **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
### 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
- **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
### 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.