refactor(ui): harmonize page layout with shared PageLayout and SubNav components#1188
Conversation
… components Create shared PageLayout and unified SubNav components to ensure consistent page structure across Project, Budget, and Settings sections. - Create PageLayout component (container, header, subNav, content slots) - Create unified SubNav component replacing 4 duplicate SubNav components - Migrate 12 pages to use PageLayout - Fix DashboardPage and MilestoneCreatePage imports - Restore ManagePage h1 heading - Remove ManagePage inner tabList border-bottom - Fix SubsidyProgramsPage hardcoded English strings - Fix ScheduleSubNav aria-label to use navigation landmark description - Add PageLayout and SubNav unit tests (27 tests) - Un-deprecate BudgetCategoriesPage heading POM locator - Clean up stale test mocks referencing deleted SettingsSubNav Fixes #1187 Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com> Co-Authored-By: Claude frontend-developer (Haiku 4.5) <noreply@anthropic.com> Co-Authored-By: Claude qa-integration-tester (Sonnet 4.5) <noreply@anthropic.com> Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.5) <noreply@anthropic.com> Co-Authored-By: Claude translator (Sonnet 4.6) <noreply@anthropic.com>
The ScheduleSubNav now uses "Schedule section navigation" as its aria-label instead of "Gantt". Update test assertions to match. Co-Authored-By: Claude qa-integration-tester (Sonnet 4.6) <noreply@anthropic.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
steilerDev
left a comment
There was a problem hiding this comment.
[product-owner]
All 11 acceptance criteria verified against the diff. This PR meets requirements. Approving (posted as comment due to GitHub self-review restriction).
AC Verification
| # | Criterion | Verdict |
|---|---|---|
| 1 | PageLayout at client/src/components/PageLayout/ |
PASS |
| 2 | Unified SubNav at client/src/components/SubNav/ |
PASS |
| 3 | All 12 affected pages use PageLayout |
PASS |
| 4 | Old per-page CSS classes removed | PASS |
| 5 | Old SubNav components deleted/replaced | PASS |
| 6 | ManagePage h1 restored | PASS |
| 7 | ManagePage tabList border-bottom removed | PASS |
| 8 | Container max-width 1200px/1400px | PASS |
| 9 | Title font-size-4xl / 2xl responsive | PASS |
| 10 | Header spacing-6 consistent | PASS |
| 11 | TimelinePage not affected | PASS |
Additional Observations (non-blocking, positive)
- SubsidyProgramsPage hardcoded English strings replaced with proper i18n keys -- good catch.
- ScheduleSubNav aria-label fixed from the Gantt tab label to "Schedule section navigation" -- correct, the old value was a bug.
- BudgetCategoriesPage POM heading locator un-deprecated to match restored h1.
- Settings page tests properly mock AuthContext instead of stubbing out SettingsSubNav.
- 27 new unit tests for the shared components.
CI is still running at time of review. Approval is contingent on Quality Gates passing.
steilerDev
left a comment
There was a problem hiding this comment.
[product-architect]
Architecture Review: PageLayout & SubNav Harmonization
Verdict: APPROVE (cannot use --approve on own PR; this review carries approval weight)
Verified Areas
1. Component Design -- GOOD
PageLayout is well-scoped: title, action, subNav, children, maxWidth, testId. The slot-based API is composable and follows existing shared component patterns (Modal, EmptyState). The SubNav component correctly extracts the common tab-rendering logic with a clean SubNavTab[] + ariaLabel interface. The TabLink inner component properly scopes useTranslation per-tab via the ns field, which is a nice touch for the schedule namespace case.
2. CSS Token Compliance -- GOOD
PageLayout.module.css uses design tokens throughout (--spacing-*, --font-size-*, --font-weight-*, --color-text-primary). The max-width: 1200px/1400px values and min-height: 44px (WCAG touch target) are structural layout values, not design tokens -- consistent with the existing codebase pattern. SubNav.module.css is a clean rename of the former BudgetSubNav.module.css with full token usage.
3. Accessibility -- GOOD
- Semantic
<nav>element witharia-labelon every SubNav instance role="list"/role="listitem"for tab navigation<h1>rendered by PageLayout for every page (restoring the ManagePage heading that was removed in #1185):focus-visiblestyling with--shadow-focus-subtleon tabs44pxminimum touch target on mobile/tablet for action buttons- ScheduleSubNav
aria-labelfix (was incorrectly using the first tab label "Gantt") - New i18n keys (
schedule.navigation.ariaLabel) for both EN and DE
4. E2E Test Preservation -- GOOD
BudgetCategoriesPage.ts correctly un-deprecates the heading locator (h1 "Manage" is back via PageLayout). The goto() readiness check still uses createFormHeading, which is sensible. The @deprecated JSDoc annotation is properly removed. data-testid attributes on schedule tabs are preserved through the testId field on SubNavTab.
5. Dead Code Removal -- GOOD
Four section-specific SubNav components fully deleted (BudgetSubNav, ProjectSubNav, SettingsSubNav with their CSS modules, ScheduleSubNav CSS). ~580 lines of duplicated CSS removed. 12 page-level CSS modules cleaned of .container, .header, .pageTitle boilerplate. The ScheduleSubNav component is retained but refactored to delegate to the shared SubNav.
6. SubsidyProgramsPage i18n Fix -- GOOD
Hardcoded English strings ("Budget", "Error", "Retry", "Loading subsidy programs...") replaced with proper t() calls. Good catch.
Medium Findings (noted for refinement, not blocking)
M1: Settings tab definition duplicated in 4 pages
BackupsPage, ManagePage, ProfilePage, and UserManagementPage each independently construct the identical settingsTabs array (including the useAuth() call for visible: isAdmin). The budget tabs (BUDGET_TABS) are duplicated across 5 files. Consider extracting these into shared constants (e.g., lib/navigationTabs.ts) in a follow-up. This is purely a DRY concern -- the current approach works correctly.
M2: ProfilePage CSS still has hardcoded rem/px values
The removed boilerplate (.container, .pageTitle) used hardcoded 2rem, 1.5rem, etc. These are now handled by PageLayout with tokens. However, the remaining ProfilePage CSS (.card, .errorCard, .button, etc.) still uses raw rem/px values like 0.5rem, 0.75rem, 0.375rem, 1rem instead of var(--spacing-*) and var(--radius-*) tokens. This is pre-existing technical debt, not introduced by this PR -- but worth noting for a future token compliance pass.
Summary
Clean, well-executed refactoring. Two new shared components with proper tests (27 for PageLayout, comprehensive SubNav coverage). Consistent migration across 12+ pages. Good accessibility improvements (ScheduleSubNav aria-label fix, ManagePage h1 restoration, SubsidyProgramsPage i18n fix). No architecture or contract concerns.
steilerDev
left a comment
There was a problem hiding this comment.
[ux-designer]
Design Review — PR #1188: Shared PageLayout + SubNav Components
Reviewed against tokens.css, shared.module.css, wiki/Style-Guide.md, and the component source.
Summary
This is a well-executed structural refactor. It correctly consolidates 4 duplicate SubNav implementations and 12+ per-page container/header patterns into two shared components with consistent token usage. The result is a significant reduction in duplication and the new components are properly spec'd for shared reuse.
Token Adherence
PageLayout.module.css — all values use design tokens. Spacing, typography, and color all reference var(--token-name). The min-height: 44px touch target literals are intentional and correct (there is no spacing token at 44px; --spacing-10 = 40px and --spacing-12 = 48px).
The two container max-widths (1200px / 1400px) are hardcoded pixel values — they are not in tokens.css. These values existed previously in individual page modules, so this PR neither introduces nor worsens the situation. I recommend adding --layout-max-width-narrow: 1200px and --layout-max-width-wide: 1400px as layout tokens to tokens.css in a follow-up, so the shared component can reference them properly. Tracking this as informational — the refactor is net-positive on token compliance overall.
SubNav.module.css (renamed from BudgetSubNav.module.css with a 96% similarity) — clean token usage throughout. All colors, spacing, font-sizes, radii, and transition durations reference var(--token-name).
ProfilePage.module.css — contains many pre-existing hardcoded values (e.g., 2rem, 0.875rem, 700, 0.375rem). These are not introduced by this PR (the diff only removes the .container/.header/.pageTitle selectors). I'm flagging these as informational pre-existing debt; they should be addressed in a dedicated cleanup story.
Dark Mode Correctness
All color references in both new CSS modules use semantic Layer 2 tokens (--color-text-primary, --color-bg-secondary, --color-primary, --color-primary-hover, --shadow-focus-subtle). All of these switch correctly under [data-theme="dark"]. No hardcoded hex or rgba values introduced. Dark mode is correct.
Responsive Behavior
PageLayout.module.css:
- Mobile (
max-width: 767px): padding reduces from--spacing-8to--spacing-4, header stacks vertically, action widens to full width withmin-height: 44px. Correct. - Tablet (
min-width: 768pxandmax-width: 1024px): action also getsmin-height: 44px. The use of1024pxas both the upper bound of the tablet range and the lower bound of the desktop breakpoint creates a one-pixel overlap. The convention used in this project ismax-width: 1023pxfor the tablet upper bound (documented in review findings for PRs #380, #398, #414). This should bemax-width: 1023px.
SubNav.module.css: Mobile padding reduction at max-width: 767px is correct and unchanged from the prior per-component implementations.
Accessibility
SubNav: <nav aria-label={ariaLabel}> → correct landmark. The tab container uses role="list" and each NavLink uses role="listitem". This is a consistent pattern carried forward from the prior components. The aria-labels ("Budget section navigation", "Project section navigation", "Schedule section navigation", "Settings section navigation") are descriptive and unique per-section.
PageLayout: Renders the page title as <h1> — establishes correct heading hierarchy for all migrated pages. Previously, several pages (BackupsPage, ProfilePage in certain states) did not render an <h1> in the loading/error early-return states. The new PageLayout renders the <h1> consistently regardless of state. This is an accessibility improvement.
ScheduleSubNav aria-label fix: The old ScheduleSubNav used the first tab's translated label ("Gantt") as the nav aria-label — a semantic error since that's a tab label, not a navigation landmark description. The fix to aria-label={t('schedule.navigation.ariaLabel')} is correct.
ARIA live regions: No new interactive widgets introduced; no live region requirements.
Focus management: SubNav.tab:focus-visible uses box-shadow: var(--shadow-focus-subtle) — correct pattern, consistent with the design system.
Visual Consistency
h1font size:PageLayoutuses--font-size-4xl(32px) at desktop, scaling to--font-size-2xl(24px) on mobile. This matches the previous per-page implementations for budget/project pages. Settings pages previously used--font-size-4xlor2rem(identical value), so no visual regression.h1missing in some prior states: Previously ManagePage had no<h1>at all (SettingsSubNav didn't render one). Now ManagePage renderst('manage.pageTitle')via PageLayout — this is a visual addition / restoration consistent with #1187.- BUDGET_TABS constant duplication: The
BUDGET_TABSarray is defined in 5 separate files (BudgetOverviewPage, BudgetSourcesPage, InvoicesPage, SubsidyProgramsPage, VendorsPage) andPROJECT_TABSin 5 files (DashboardPage, HouseholdItemsPage, MilestonesPage, MilestoneCreatePage, WorkItemsPage). These should be extracted to shared constants (e.g.,client/src/lib/navTabs.ts) to avoid divergence risk. Flagging as informational — worth noting in a follow-up.
prefers-reduced-motion
SubNav.module.css has transition: color var(--transition-normal), background-color var(--transition-normal) on .tab. There is no prefers-reduced-motion guard. This carries forward from the original per-component implementations and is a minor consistency gap with newer components. Non-blocking, but worth addressing when the SubNav CSS is next touched.
Component Reuse Audit
| UI Element | Shared Component | Verdict |
|---|---|---|
| Page container + header | PageLayout (new) |
New shared component, correctly built for reuse |
| Sub-navigation tabs | SubNav (new) |
Consolidates 4 duplicates; correct shared component |
| Conditional tab visibility | SubNavTab.visible prop |
Generic — properly designed for admin-only tabs |
No one-off implementations introduced. The new components are generic and reusable.
Findings Summary
| Severity | Finding |
|---|---|
| Medium | Tablet breakpoint upper bound max-width: 1024px in PageLayout.module.css should be max-width: 1023px to avoid overlap with the min-width: 768px tablet range |
| Informational | 1200px / 1400px max-widths in PageLayout are not design tokens; suggest adding --layout-max-width-narrow / --layout-max-width-wide to tokens.css |
| Informational | BUDGET_TABS and PROJECT_TABS constants duplicated across 5+ files each — extract to client/src/lib/navTabs.ts |
| Informational | SubNav.module.css .tab transition has no prefers-reduced-motion guard (pre-existing from prior implementations) |
| Informational | ProfilePage.module.css contains pre-existing hardcoded values not introduced by this PR; track as separate cleanup |
The medium finding (tablet breakpoint overlap) is a non-blocking correction — the overlap window is a single pixel at exactly 1024px width and unlikely to produce a visible artifact. Fix before merge or in refinement.
- Change PageLayout tablet media query from max-width: 1024px to 1023px to avoid overlap with the mobile breakpoint upper bound - Add prefers-reduced-motion: reduce guard to SubNav tab transitions Co-Authored-By: Claude frontend-developer (Haiku 4.5) <noreply@anthropic.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ion slot - Migrate DashboardPage to use PageLayout with Add/Customize dropdowns as the action slot - Move BudgetSourcesPage "Add Source" button to PageLayout action slot - Move SubsidyProgramsPage "Add Program" button to PageLayout action slot - Fix SubsidyProgramsPage hardcoded "Add Program" string with i18n key - Change BudgetSourcesPage maxWidth from narrow to wide for consistency with other budget list pages - Remove stale .container/.pageTitle CSS from BudgetSourcesPage responsive media queries - Remove stale .page/.pageHeader/.title CSS from DashboardPage Fixes #1187 Co-Authored-By: Claude frontend-developer (Haiku 4.5) <noreply@anthropic.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rename .containerWide → .container and .container → .containerNarrow. Default maxWidth is now 'wide' since most pages use 1400px. Only the 4 settings pages (Profile, Manage, UserManagement, Backups) use maxWidth="narrow" (1200px). Co-Authored-By: Claude frontend-developer (Haiku 4.5) <noreply@anthropic.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
🎉 This PR is included in version 2.2.0-beta.31 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
|
🎉 This PR is included in version 2.2.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
The SubNav component forced role='list' on the container <div> and
role='listitem' on each NavLink. Overriding the default role of an
<a> to 'listitem' makes it invisible to role=link accessibility queries,
breaking several E2E tests that navigate via getByRole('link', ...)
after the #1188 PageLayout refactor (invoices Budget subnav, settings
subnav across desktop/tablet/mobile).
Removing both role attributes lets NavLink expose its native anchor
role. Update SubNav unit tests to assert link semantics accordingly.
…1255) * chore(ci): remove CLA check and stale doc references The CLA workflow is optional (not a required status check on beta or main — branch rulesets require only Quality Gates / E2E Gates). As the sole contributor, the self-signed CLA provides no value. - Delete CLA.md, .github/workflows/cla.yml, .github/cla/signatures.json - Strip stale "+ CLA" mentions from /develop, /epic-close, /release skill docs * fix(milestones): bypass browser native validation to surface custom error banner HTML5 required on the title/targetDate inputs blocked form submission before the onSubmit handler could call setError(...), so the custom error banner (role=alert) never rendered. E2E Scenarios 6/7 on the milestone create page asserted on a null banner text and failed across desktop/tablet/mobile. Adding noValidate to both the create form and the detail-page edit form lets the JS validation handler run and populate the errorBanner as the tests expect. The required attribute stays for screen-reader hinting. * fix(subnav): drop misused list/listitem role overrides The SubNav component forced role='list' on the container <div> and role='listitem' on each NavLink. Overriding the default role of an <a> to 'listitem' makes it invisible to role=link accessibility queries, breaking several E2E tests that navigate via getByRole('link', ...) after the #1188 PageLayout refactor (invoices Budget subnav, settings subnav across desktop/tablet/mobile). Removing both role attributes lets NavLink expose its native anchor role. Update SubNav unit tests to assert link semantics accordingly. * test(e2e): fix broken locators and races exposed by recent CI - MilestonesPage.getMilestoneTitles: the cardCell class does not exist on DataTableCard (which uses cardRow/cardValue). Scope the card iterator to top-level .card children (class^='card_') and read the title from .cardValue. Fixes the 3 mobile milestones scenarios (API create, delete confirm, delete cancel). - invoices.spec.ts Scenario 2: pending summary count is returned from the same API response as the list, but a brief interleaving window after invoice creation causes the list render to show stale counts. Poll the summary count with expect.poll instead of a one-shot read. - invoices.spec.ts Scenario 7: DataTable renders both the desktop table and the mobile cards simultaneously and toggles visibility via CSS media queries. locator('.invoiceLink').first() picked the hidden desktop <a> on mobile. Add :visible so the currently shown link is clicked. - area-filter.spec.ts Scenario 1: the Area column is defaultVisible false, so its in-header Filter by Area button is never rendered. Drop the secondary assertion on the filter-button visibility; the core URL-based areaId filter behaviour is still fully validated. * test(e2e): tighten WorkItemsPage emptyState locator [class*=emptyState] can match unrelated elements with classes like emptyStateTitle/emptyStateDescription. Scope to the CSS-module-hashed prefix emptyState_ so only the EmptyState component wrapper is picked up. Aimed at the tablet filter-empty-state flake on shard 12. * test(e2e): update SubNav-dependent tests to use role=link After the SubNav role override was dropped, tests that relied on role=listitem on nav tabs need to use role=link. Covers: - budget/vendors.spec.ts (Vendors tab) - budget/budget-sources.spec.ts (Sources tab) - budget/budget-overview.spec.ts (four budget tabs) - budget/subsidy-programs.spec.ts (Subsidies tab) - admin/backup-restore.spec.ts (Backups tab) + remove outdated comment - i18n/i18n.spec.ts (Auftragnehmer tab) * test: scope SubNav link queries and fix invoice-create status default - MilestoneCreatePage.test.tsx: the back-link regex `/back|milestones/i` now collides with the SubNav Milestones tab (both role=link after the SubNav role override was dropped). Tighten to `/←\s*milestones/i`. - invoices.spec.ts Scenario 1 (Budget subnav tabs): scope getByRole('link', 'Overview') to the Budget <nav> landmark so it does not collide with the project-logo link's aria-label ("Go to project overview"). - invoices.spec.ts Scenario 2 (Create invoice pending count): the create form defaults status to 'quotation' now (previously 'pending'). Explicitly select 'pending' on the status select so the test's pending-summary assertion actually reflects the created invoice. - settings-manage.spec.ts: same scoping fix for Profile / Manage SubNav tabs. * test(milestones): narrow detail-page back-link regex Same collision as MilestoneCreatePage after the SubNav role change: the /back|milestones/i regex now matches both the header back link and the SubNav Milestones tab. Tighten to /←\s*milestones/i. * test: second-pass SubNav scoping fixes - MilestoneDetailPage.test.tsx (not-found state): the not-found branch renders without SubNav and uses 'milestones.detail.backLink' which translates to 'Back to Milestones', not the header-format arrow. Restore the broader 'back to milestones' regex for this case. - settings-manage.spec.ts (subnav test): add 'exact: true' so the 'Manage' link query does not collide with 'User Management' tab. --------- Co-authored-by: Frank Steiler <frank@steiler.de>
Summary
PageLayoutcomponent (container, header, subNav, content slots) and unifiedSubNavcomponent to replace 4 duplicate section-specific SubNav implementationsFixes #1187
Test plan
schedule.navigation.ariaLabeladdedCo-Authored-By: Claude dev-team-lead (Sonnet 4.6) noreply@anthropic.com