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

@@ -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