From c81c0dfe659385ec36c3fa29e055635307c2de28 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 04:03:19 +0700 Subject: [PATCH] fix(ui): complete reviewed desktop integration --- docs/tests/web/review-round-1-shared-ui.md | 36 ++++++++++++------- src/main/frontend/app.css | 20 +++++++++++ src/main/resources/static/assets/app.css | 2 +- src/main/resources/static/assets/app.js | 11 ++++-- .../templates/fragments/auth-layout.html | 1 + .../resources/templates/fragments/layout.html | 19 +++++----- src/main/resources/templates/smtp/defer.html | 2 +- src/main/resources/templates/smtp/form.html | 2 +- .../controller/AttendanceControllerTest.java | 4 +++ .../controller/ProjectControllerTest.java | 4 +++ .../AccountTemplateIntegrationTest.java | 18 ++++++++-- .../AttendanceTemplateIntegrationTest.java | 11 ++++-- .../DashboardControllerWebTest.java | 4 +++ .../controller/DashboardTemplateWebTest.java | 5 +++ .../ProjectTaskFormAccessibilityWebTest.java | 4 +++ .../SharedErrorTemplateWebTest.java | 5 +++ .../task/controller/TaskControllerTest.java | 4 +++ .../labtimesheet/ui/UiContractWebTest.java | 29 +++++++++++++++ .../resources/templates/error/generic.html | 10 ------ 19 files changed, 148 insertions(+), 43 deletions(-) delete mode 100644 src/test/resources/templates/error/generic.html diff --git a/docs/tests/web/review-round-1-shared-ui.md b/docs/tests/web/review-round-1-shared-ui.md index 40f7121..044a977 100644 --- a/docs/tests/web/review-round-1-shared-ui.md +++ b/docs/tests/web/review-round-1-shared-ui.md @@ -4,19 +4,19 @@ - **Requirement IDs:** `AUTH-002`, `UI-003`, `UI-004`, `UI-010`, `UI-013`, `UI-014`, `ERR-001`, `I1-UI-01`, `I1-UI-02`, `I1-UI-04` - **Scenario IDs:** `AC-AUTH-001`, `AC-UI-002`, `AC-UI-003`, `AC-UI-005` - **Test class/method:** `com.lab.labtimesheet.ui.UiContractWebTest`, `com.lab.labtimesheet.feature.reporting.controller.AttendanceTemplateIntegrationTest#populatedHistoryUsesPolicyLocalPresentationAndListsEveryViolation`, `com.lab.labtimesheet.feature.reporting.controller.SharedErrorTemplateWebTest`, `com.lab.labtimesheet.feature.reporting.controller.ProjectTaskFormAccessibilityWebTest`, `com.lab.labtimesheet.feature.reporting.controller.RoleDashboardWebIntegrationTest#mentorAndInternDashboardsRenderRealScopedProjectTaskAndAttendanceData` -- **Implementation commit:** `8388b4c` +- **Implementation commit:** `pending final review-fix commit` ## Protected behavior -The authenticated shell exposes only reachable role-authorized links. Intern attendance uses `/attendance`; Mentor attendance, profile, and notification links remain hidden until their authorized destination flows exist. Every rendered role-navigation link resolves through an actual authenticated GET. Attendance history uses the row's attached policy timezone for 24-hour times, formats business dates as `dd/MM/yyyy`, and renders every simultaneous violation. Project and Task field errors have stable IDs associated to invalid controls. Generic 404 and 409 pages use the shared shell and safe caller-supplied copy without rendering exception details. +The authenticated shell exposes only reachable role-authorized links. Intern attendance uses `/attendance`; Mentor attendance, profile, and notification links remain hidden until their authorized destination flows exist. Every rendered role-navigation link resolves through an actual authenticated GET. Attendance history uses the row's attached policy timezone for 24-hour times, formats business dates as `dd/MM/yyyy`, and renders every simultaneous violation. Project and Task field errors have stable IDs associated to invalid controls. Generic 404 and 409 pages use the shared shell and safe caller-supplied copy without rendering exception details. The collapsed desktop sidebar exposes its current state, keeps every control within its rail, and gives icon-only navigation a visible keyboard-focus tooltip. Both shared shells explicitly reference a local favicon so browser console checks do not depend on an unmapped `/favicon.ico` request. ## Test method -MockMvc renders the production shell and templates with real Spring Security principals and production-shaped Attendance DTOs. Project and Task invalid POSTs pass through their real controllers and validation, with only feature services replaced at the slice boundary. The full Spring/PostgreSQL role journey creates accounts, internship, Project, and Task through public services, renders each role's real dashboard, extracts every visible shell link, and performs an authenticated GET against each extracted path. +MockMvc renders the production shell and templates with real Spring Security principals and production-shaped Attendance DTOs. Project and Task invalid POSTs pass through their real controllers and validation, with only feature services replaced at the slice boundary. The full Spring/PostgreSQL role journey creates accounts, internship, Project, and Task through public services, renders each role's real dashboard, extracts every visible shell link, and performs an authenticated GET against each extracted path. A desktop browser then exercises the real local Java process at 1365x900 for all three roles, inspecting focus, tooltip pseudo-content, runtime `aria-expanded`, theme persistence/head ordering, console output, and document overflow. ## Hand-derived expected result -Mentor navigation contains only overview and owned Projects; Intern navigation contains overview, `/attendance`, and Projects; Admin navigation contains overview, account creation, and global calendar. No role receives `/attendance/me`, `/profile`, `/notifications`, or a selector-less Mentor attendance destination. `2026-08-14T02:05:00Z` under `Asia/Ho_Chi_Minh` renders as `14/08/2026 09:05`; `09:00:00Z` renders as `16:00`. Late plus early-departure and late plus missing-checkout labels are both retained. Every rendered validation message has a stable referenced ID. Error pages expose only status and generic copy. +Mentor navigation contains only overview and owned Projects; Intern navigation contains overview, `/attendance`, and Projects; Admin navigation contains overview, account creation, and global calendar. No role receives `/attendance/me`, `/profile`, `/notifications`, or a selector-less Mentor attendance destination. `2026-08-14T02:05:00Z` under `Asia/Ho_Chi_Minh` renders as `14/08/2026 09:05`; `09:00:00Z` renders as `16:00`. Late plus early-departure and late plus missing-checkout labels are both retained. Every rendered validation message has a stable referenced ID. Error pages expose only status and generic copy. At 1365x900, root and body scroll widths remain 1365, the collapsed toggle reports `aria-expanded=false`, expanding reports `true`, and keyboard focus exposes the corresponding control name without horizontal overflow. ## RED @@ -28,6 +28,9 @@ export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw -Dtest=UiContractWebTest,AttendanceTemplateIntegrationTest,SharedErrorTemplateWebTest,ProjectTaskFormAccessibilityWebTest test ./mvnw -Dtest=RoleDashboardWebIntegrationTest test +./mvnw -Dtest=ProjectControllerTest,AttendanceControllerTest,TaskControllerTest test +./mvnw -Dtest=UiContractWebTest#collapsedSidebarExposesStateAndKeyboardVisibleControlNames test +./mvnw -Dtest=UiContractWebTest#mentorShellRendersOnlyReachableAuthorizedNavigation test ``` **Observed result** @@ -41,10 +44,14 @@ Invalid controls had no aria-describedby and inline errors had no stable IDs. PostgreSQL 18.4 role journey: Tests run: 1, Failures: 1, Errors: 0, Skipped: 0 Following the Admin shell's visible /profile link returned 404 instead of 200. + +After the four reviewed producer pins were merged, the three producer WebMvc slices ran 39 tests with 39 context errors. The merged Platform SmtpWarningAdvice required SmtpConfigurationService, which was absent only from those narrow slice fixtures; no behavior assertion ran. + +The collapsed-sidebar regression failed 1/1 at the missing aria-expanded assertion. The favicon regression failed 1/1 because the rendered shared shell had no explicit local icon link; the real browser independently logged /favicon.ico as 404. BUILD FAILURE ``` -The failures occurred after real template rendering and controller validation; they identify the missing reviewed behavior rather than fixture or environment failure. +The failures occurred after real template rendering and controller validation, or at an exact missing merged slice dependency; they identify the missing reviewed behavior or fixture boundary rather than an unrelated environment failure. ## GREEN @@ -54,15 +61,21 @@ The failures occurred after real template rendering and controller validation; t export JAVA_HOME=/opt/homebrew/opt/openjdk@25 export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock -./mvnw -Dtest=UiContractWebTest,AttendanceTemplateIntegrationTest,SharedErrorTemplateWebTest,ProjectTaskFormAccessibilityWebTest test +./mvnw clean -Dtest=AccountTemplateIntegrationTest,AttendanceTemplateIntegrationTest,DashboardControllerWebTest,DashboardTemplateWebTest,ProjectTaskFormAccessibilityWebTest,SharedErrorTemplateWebTest,UiContractWebTest test ./mvnw -Dtest=RoleDashboardWebIntegrationTest test +./mvnw -Dtest=ProjectControllerTest,AttendanceControllerTest,TaskControllerTest test +./mvnw -Dtest=UiContractWebTest#collapsedSidebarExposesStateAndKeyboardVisibleControlNames test +./mvnw -Dtest=UiContractWebTest#mentorShellRendersOnlyReachableAuthorizedNavigation test ``` **Observed result** ```text -Focused templates: Tests run: 13, Failures: 0, Errors: 0, Skipped: 0 +Post-merge clean Reporting/UI slices: Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 PostgreSQL 18.4 role journey: Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +Producer WebMvc slices: Tests run: 39, Failures: 0, Errors: 0, Skipped: 0 +Collapsed sidebar: Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +Local favicon contract: Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` @@ -75,14 +88,13 @@ export JAVA_HOME=/opt/homebrew/opt/openjdk@25 export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock npm run build -./mvnw -Dtest=UiContractWebTest,AttendanceTemplateIntegrationTest,AttendanceControllerTest,SharedErrorTemplateWebTest,ProjectTaskFormAccessibilityWebTest,ProjectControllerTest,TaskControllerTest,RoleDashboardWebIntegrationTest test +./mvnw -Dtest=SecurityResponseIntegrationTest,BootstrapOnboardingWebIntegrationTest,AccountWebIntegrationTest,SmtpOnboardingWebIntegrationTest,RoleDashboardWebIntegrationTest,UiContractWebTest,AccountTemplateIntegrationTest,AttendanceTemplateIntegrationTest,DashboardControllerWebTest,DashboardTemplateWebTest,ProjectTaskFormAccessibilityWebTest,SharedErrorTemplateWebTest,ProjectControllerTest,TaskControllerTest,AttendanceControllerTest test ./mvnw -DskipTests compile ./mvnw -DskipTests -Ddoclint=all javadoc:javadoc Node v24.19.0; npm 11.17.0 -Tailwind CSS v4.3.3: Done in 68ms -PostgreSQL 18.4 via Testcontainers -Tests run: 40, Failures: 0, Errors: 0, Skipped: 0 +Tailwind CSS v4.3.3: Done +Merged affected web suite on PostgreSQL 18.4: Tests run: 79, Failures: 0, Errors: 0, Skipped: 0 Compile: success Javadoc/doclint: success BUILD SUCCESS @@ -90,4 +102,4 @@ BUILD SUCCESS ## External-test boundaries -The automated checks prove rendering, controller validation, role-scoped navigation targets, attached-policy formatting, and generic error copy. They do not prove first-paint timing, keyboard focus/tooltips, runtime `aria-expanded` synchronization, or viewport overflow; those remain mandatory live desktop browser gates after the corrected producer pins are merged. +The automated checks prove rendering, controller validation, role-scoped navigation targets, attached-policy formatting, generic error copy, public local assets, safe Referrer-Policy, and retained safe form fields. Edge/Chromium desktop checks against the real local Java/PostgreSQL process covered Admin dashboard, Mentor dashboard/Projects, and Intern dashboard/attendance: every representative page had `documentElement.scrollWidth == body.scrollWidth == innerWidth == 1365`; keyboard focus showed a solid focus ring and tooltip; collapse/expand synchronized `aria-expanded`; the theme bootstrap preceded CSS and survived reload; console checks were empty after the explicit local favicon link. A human-observed no-flash check is inherently practical rather than deterministic, and mobile remains outside Iteration 1 scope. diff --git a/src/main/frontend/app.css b/src/main/frontend/app.css index b5a677c..100a415 100644 --- a/src/main/frontend/app.css +++ b/src/main/frontend/app.css @@ -81,10 +81,30 @@ .nav-list { display: grid; gap: .2rem; margin: 0; padding: 0; list-style: none; } .nav-link { display: flex; min-height: 2.5rem; align-items: center; gap: .7rem; border-radius: .55rem; padding: .55rem .7rem; color: var(--muted); font-weight: 600; text-decoration: none; } .nav-link:hover, .nav-link[aria-current="page"] { background: var(--panel); color: var(--ink); box-shadow: 0 1px 2px rgb(20 25 35 / .08); } + .nav-link[data-tooltip] { position: relative; } + [data-sidebar-collapsed="true"] .nav-link[data-tooltip]:hover::after, + [data-sidebar-collapsed="true"] .nav-link[data-tooltip]:focus-visible::after { + position: absolute; + z-index: 20; + top: 50%; + left: calc(100% + .75rem); + padding: .38rem .55rem; + border: 1px solid var(--border-strong); + border-radius: .4rem; + background: var(--ink); + color: var(--panel); + content: attr(data-tooltip); + font-size: .75rem; + line-height: 1; + pointer-events: none; + transform: translateY(-50%); + white-space: nowrap; + } .nav-icon { width: 1.05rem; height: 1.05rem; flex: 0 0 auto; } .sidebar-footer { display: grid; gap: .7rem; margin-top: auto; } .theme-field { display: grid; gap: .25rem; } .theme-field select { min-height: 2.4rem; border: 1px solid var(--border-strong); border-radius: .5rem; background: var(--panel); color: var(--ink); padding: .35rem .55rem; } + [data-sidebar-collapsed="true"] .theme-field select { width: 2.5rem; padding-inline: .25rem; font-size: 0; } .logout-form button { width: 100%; border: 0; background: transparent; text-align: left; } .app-column { min-width: 0; } .app-header { display: flex; min-height: 3.75rem; align-items: center; gap: .8rem; border-bottom: 1px solid var(--border); padding: 0 1.5rem; } diff --git a/src/main/resources/static/assets/app.css b/src/main/resources/static/assets/app.css index 450c03d..9b446c9 100644 --- a/src/main/resources/static/assets/app.css +++ b/src/main/resources/static/assets/app.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{box-sizing:border-box}html{background:var(--canvas);min-width:64rem}body{background:var(--canvas);color:var(--ink);margin:0;font:14px/1.45 ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;overflow-x:hidden}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline:none}:focus-visible{outline:3px solid var(--focus);outline-offset:2px}a{color:inherit}}@layer components{.app-shell{grid-template-columns:16rem minmax(0,1fr);min-height:100vh;display:grid}.auth-shell{min-height:100vh}.auth-header{border-bottom:1px solid var(--border);justify-content:space-between;align-items:center;min-height:4rem;padding:.75rem 1.25rem;display:flex}.auth-theme{width:9rem}.auth-main{place-items:center;min-height:calc(100vh - 4rem);padding:2rem;display:grid}.auth-card{border:1px solid var(--border);background:var(--panel);border-radius:.85rem;width:min(100%,28rem);padding:1.5rem;box-shadow:0 16px 42px #14192314}.auth-eyebrow{color:var(--muted);letter-spacing:.08em;text-transform:uppercase;margin:0 0 .35rem;font-size:.72rem;font-weight:750}.auth-form{margin-top:1.25rem}[data-sidebar-collapsed=true] .app-shell{grid-template-columns:4rem minmax(0,1fr)}.sidebar{border-right:1px solid var(--border);background:var(--sidebar);flex-direction:column;height:100vh;padding:1rem .75rem;display:flex;position:sticky;top:0}.brand,.account{align-items:center;gap:.7rem;min-width:0;padding:.25rem .4rem;display:flex}.brand-mark{background:var(--ink);width:2rem;height:2rem;color:var(--panel);border-radius:.55rem;flex:none;place-items:center;display:grid}.sidebar-label{white-space:nowrap;overflow:hidden}[data-sidebar-collapsed=true] .sidebar-label{opacity:0;width:0}.nav-label{color:var(--subtle);letter-spacing:.08em;text-transform:uppercase;margin:1.6rem .6rem .4rem;font-size:.68rem;font-weight:750}.nav-list{gap:.2rem;margin:0;padding:0;list-style:none;display:grid}.nav-link{min-height:2.5rem;color:var(--muted);border-radius:.55rem;align-items:center;gap:.7rem;padding:.55rem .7rem;font-weight:600;text-decoration:none;display:flex}.nav-link:hover,.nav-link[aria-current=page]{background:var(--panel);color:var(--ink);box-shadow:0 1px 2px #14192314}.nav-icon{flex:none;width:1.05rem;height:1.05rem}.sidebar-footer{gap:.7rem;margin-top:auto;display:grid}.theme-field{gap:.25rem;display:grid}.theme-field select{border:1px solid var(--border-strong);background:var(--panel);min-height:2.4rem;color:var(--ink);border-radius:.5rem;padding:.35rem .55rem}.logout-form button{text-align:left;background:0 0;border:0;width:100%}.app-column{min-width:0}.app-header{border-bottom:1px solid var(--border);align-items:center;gap:.8rem;min-height:3.75rem;padding:0 1.5rem;display:flex}.header-title{min-width:0;font-weight:700}.breadcrumb{color:var(--muted);font-weight:400}.header-actions{align-items:center;gap:.55rem;margin-left:auto;display:flex}.icon-button{border:1px solid var(--border-strong);background:var(--panel);width:2.5rem;height:2.5rem;color:var(--ink);cursor:pointer;border-radius:.5rem;place-items:center;display:inline-grid}.page{min-width:0;padding:1.55rem}.page-heading{align-items:end;gap:1rem;margin-bottom:1.1rem;display:flex}.page-heading-copy{min-width:0}.page-title{letter-spacing:-.025em;margin:0;font-size:1.56rem;line-height:1.2}.page-description{max-width:72ch;color:var(--muted);margin:.3rem 0 0}.primary-action{margin-left:auto}.button{border:1px solid var(--border-strong);background:var(--panel);min-height:2.35rem;color:var(--ink);cursor:pointer;border-radius:.5rem;justify-content:center;align-items:center;gap:.45rem;padding:.5rem .8rem;font-weight:650;text-decoration:none;display:inline-flex}.button-primary{border-color:var(--ink);background:var(--ink);color:var(--panel)}.button-danger{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{border-color:color-mix(in srgb, var(--danger), transparent 65%)}}.button-danger{background:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{background:color-mix(in srgb, var(--danger), transparent 90%)}}.button-danger{color:var(--danger)}.panel{border:1px solid var(--border);background:var(--panel);border-radius:.75rem;box-shadow:0 10px 28px #1419230f}.panel-header{border-bottom:1px solid var(--border);padding:.9rem 1rem}.panel-title{margin:0;font-size:1rem}.metric-strip{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:1rem;display:grid;overflow:hidden}.metric-strip-three{grid-template-columns:repeat(3,minmax(0,1fr))}.metric{min-width:0;padding:1rem}.metric+.metric{border-left:1px solid var(--border)}.metric-label{color:var(--muted);font-size:.78rem}.metric-value{font-variant-numeric:tabular-nums;margin-top:.35rem;font-size:1.4rem;font-weight:700}.metric-detail{color:var(--muted);margin-top:.18rem;font-size:.78rem}.field{gap:.35rem;display:grid}.form-panel{margin-top:1rem;padding:1rem}.form-grid{gap:1rem;display:grid}.form-grid-three{grid-template-columns:repeat(3,minmax(0,1fr))}.form-section{border:1px solid var(--border);border-radius:.65rem;padding:1rem}.form-section legend{padding:0 .35rem;font-weight:700}.field-help{color:var(--muted);margin:0 0 .8rem;font-size:.78rem}.form-actions{justify-content:flex-end;gap:.6rem;display:flex}.inline-actions{gap:.6rem;margin:1rem 0;display:flex}.filter-form{grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;align-items:end;gap:.8rem;margin:1rem 0;display:grid}.field-label{font-size:.78rem;font-weight:650}.control{border:1px solid var(--border-strong);background:var(--panel);width:100%;min-height:2.45rem;color:var(--ink);border-radius:.5rem;padding:.55rem .65rem}.control[aria-invalid=true]{border-color:var(--danger)}.field-error{color:var(--danger);margin:0;font-size:.78rem}.checkbox{align-items:center;gap:.5rem;display:flex}.badge{border:1px solid var(--border);border-radius:999px;align-items:center;gap:.32rem;padding:.15rem .45rem;font-size:.72rem;font-weight:700;display:inline-flex}.badge:before{content:"";background:currentColor;border-radius:50%;width:.38rem;height:.38rem}.badge-success{color:var(--success)}.badge-warning{color:var(--warning)}.badge-danger{color:var(--danger)}.alert{border:1px solid var(--border);border-radius:.6rem;margin:.75rem 0;padding:.75rem .9rem}.alert-error{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in srgb, var(--danger), transparent 60%)}}.alert-error{color:var(--danger)}.empty-state{text-align:center;padding:2.5rem 1rem}.empty-state p{color:var(--muted);margin:.3rem auto 0}.table-scroll{max-width:100%;overflow-x:auto}.data-table{border-collapse:collapse;width:100%;min-width:42rem}.data-table th{background:var(--panel-muted);color:var(--muted);letter-spacing:.06em;text-align:left;text-transform:uppercase;font-size:.69rem}.data-table th,.data-table td{border-bottom:1px solid var(--border);padding:.7rem 1rem}.data-table tr:last-child td{border-bottom:0}.tabs{border:1px solid var(--border);background:var(--panel-muted);border-radius:.55rem;gap:.2rem;padding:.2rem;display:inline-flex}.tab{border-radius:.4rem;padding:.4rem .65rem;text-decoration:none}.tab[aria-current=page]{background:var(--panel);box-shadow:0 1px 2px #14192314}.pagination{justify-content:flex-end;align-items:center;gap:.4rem;padding:.8rem 1rem;display:flex}.skeleton{background:var(--panel-muted);border-radius:.35rem;height:1rem;animation:1.5s ease-in-out infinite pulse}.notification-menu{min-width:18rem;padding:.75rem}dialog{border:1px solid var(--border);background:var(--panel);max-width:30rem;color:var(--ink);border-radius:.9rem;padding:1.25rem}dialog::backdrop{background:#00000073}@keyframes pulse{50%{opacity:.45}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;animation-duration:.01ms!important}}}@layer utilities{.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.fixed{position:fixed}.relative{position:relative}.static{position:static}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.hidden{display:none}.inline{display:inline}.table{display:table}.border{border-style:var(--tw-border-style);border-width:1px}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}}:root{color-scheme:light;--ink:#15171a;--canvas:#f6f7f8;--sidebar:#f0f1f2;--panel:#fff;--panel-muted:#f7f8f9;--border:#858c96;--border-strong:#747d89;--muted:#626a75;--subtle:#626a75;--accent:#3157e7;--focus:#3157e7;--success:#087a48;--warning:#7a4d00;--danger:#b42318}:root[data-theme=dark]{color-scheme:dark;--ink:#eceef1;--canvas:#0b0c0e;--sidebar:#111317;--panel:#17191e;--panel-muted:#1d2026;--border:#626b78;--border-strong:#707987;--muted:#b2b7c0;--subtle:#969da8;--accent:#8ca4ff;--focus:#9eb2ff;--success:#4fd19b;--warning:#f0bc63;--danger:#ff8e88}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{box-sizing:border-box}html{background:var(--canvas);min-width:64rem}body{background:var(--canvas);color:var(--ink);margin:0;font:14px/1.45 ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;overflow-x:hidden}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline:none}:focus-visible{outline:3px solid var(--focus);outline-offset:2px}a{color:inherit}}@layer components{.app-shell{grid-template-columns:16rem minmax(0,1fr);min-height:100vh;display:grid}.auth-shell{min-height:100vh}.auth-header{border-bottom:1px solid var(--border);justify-content:space-between;align-items:center;min-height:4rem;padding:.75rem 1.25rem;display:flex}.auth-theme{width:9rem}.auth-main{place-items:center;min-height:calc(100vh - 4rem);padding:2rem;display:grid}.auth-card{border:1px solid var(--border);background:var(--panel);border-radius:.85rem;width:min(100%,28rem);padding:1.5rem;box-shadow:0 16px 42px #14192314}.auth-eyebrow{color:var(--muted);letter-spacing:.08em;text-transform:uppercase;margin:0 0 .35rem;font-size:.72rem;font-weight:750}.auth-form{margin-top:1.25rem}[data-sidebar-collapsed=true] .app-shell{grid-template-columns:4rem minmax(0,1fr)}.sidebar{border-right:1px solid var(--border);background:var(--sidebar);flex-direction:column;height:100vh;padding:1rem .75rem;display:flex;position:sticky;top:0}.brand,.account{align-items:center;gap:.7rem;min-width:0;padding:.25rem .4rem;display:flex}.brand-mark{background:var(--ink);width:2rem;height:2rem;color:var(--panel);border-radius:.55rem;flex:none;place-items:center;display:grid}.sidebar-label{white-space:nowrap;overflow:hidden}[data-sidebar-collapsed=true] .sidebar-label{opacity:0;width:0}.nav-label{color:var(--subtle);letter-spacing:.08em;text-transform:uppercase;margin:1.6rem .6rem .4rem;font-size:.68rem;font-weight:750}.nav-list{gap:.2rem;margin:0;padding:0;list-style:none;display:grid}.nav-link{min-height:2.5rem;color:var(--muted);border-radius:.55rem;align-items:center;gap:.7rem;padding:.55rem .7rem;font-weight:600;text-decoration:none;display:flex}.nav-link:hover,.nav-link[aria-current=page]{background:var(--panel);color:var(--ink);box-shadow:0 1px 2px #14192314}.nav-link[data-tooltip]{position:relative}[data-sidebar-collapsed=true] .nav-link[data-tooltip]:hover:after,[data-sidebar-collapsed=true] .nav-link[data-tooltip]:focus-visible:after{z-index:20;border:1px solid var(--border-strong);background:var(--ink);color:var(--panel);content:attr(data-tooltip);pointer-events:none;white-space:nowrap;border-radius:.4rem;padding:.38rem .55rem;font-size:.75rem;line-height:1;position:absolute;top:50%;left:calc(100% + .75rem);transform:translateY(-50%)}.nav-icon{flex:none;width:1.05rem;height:1.05rem}.sidebar-footer{gap:.7rem;margin-top:auto;display:grid}.theme-field{gap:.25rem;display:grid}.theme-field select{border:1px solid var(--border-strong);background:var(--panel);min-height:2.4rem;color:var(--ink);border-radius:.5rem;padding:.35rem .55rem}[data-sidebar-collapsed=true] .theme-field select{width:2.5rem;padding-inline:.25rem;font-size:0}.logout-form button{text-align:left;background:0 0;border:0;width:100%}.app-column{min-width:0}.app-header{border-bottom:1px solid var(--border);align-items:center;gap:.8rem;min-height:3.75rem;padding:0 1.5rem;display:flex}.header-title{min-width:0;font-weight:700}.breadcrumb{color:var(--muted);font-weight:400}.header-actions{align-items:center;gap:.55rem;margin-left:auto;display:flex}.icon-button{border:1px solid var(--border-strong);background:var(--panel);width:2.5rem;height:2.5rem;color:var(--ink);cursor:pointer;border-radius:.5rem;place-items:center;display:inline-grid}.page{min-width:0;padding:1.55rem}.page-heading{align-items:end;gap:1rem;margin-bottom:1.1rem;display:flex}.page-heading-copy{min-width:0}.page-title{letter-spacing:-.025em;margin:0;font-size:1.56rem;line-height:1.2}.page-description{max-width:72ch;color:var(--muted);margin:.3rem 0 0}.primary-action{margin-left:auto}.button{border:1px solid var(--border-strong);background:var(--panel);min-height:2.35rem;color:var(--ink);cursor:pointer;border-radius:.5rem;justify-content:center;align-items:center;gap:.45rem;padding:.5rem .8rem;font-weight:650;text-decoration:none;display:inline-flex}.button-primary{border-color:var(--ink);background:var(--ink);color:var(--panel)}.button-danger{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{border-color:color-mix(in srgb, var(--danger), transparent 65%)}}.button-danger{background:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{background:color-mix(in srgb, var(--danger), transparent 90%)}}.button-danger{color:var(--danger)}.panel{border:1px solid var(--border);background:var(--panel);border-radius:.75rem;box-shadow:0 10px 28px #1419230f}.panel-header{border-bottom:1px solid var(--border);padding:.9rem 1rem}.panel-title{margin:0;font-size:1rem}.metric-strip{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:1rem;display:grid;overflow:hidden}.metric-strip-three{grid-template-columns:repeat(3,minmax(0,1fr))}.metric{min-width:0;padding:1rem}.metric+.metric{border-left:1px solid var(--border)}.metric-label{color:var(--muted);font-size:.78rem}.metric-value{font-variant-numeric:tabular-nums;margin-top:.35rem;font-size:1.4rem;font-weight:700}.metric-detail{color:var(--muted);margin-top:.18rem;font-size:.78rem}.field{gap:.35rem;display:grid}.form-panel{margin-top:1rem;padding:1rem}.form-grid{gap:1rem;display:grid}.form-grid-three{grid-template-columns:repeat(3,minmax(0,1fr))}.form-section{border:1px solid var(--border);border-radius:.65rem;padding:1rem}.form-section legend{padding:0 .35rem;font-weight:700}.field-help{color:var(--muted);margin:0 0 .8rem;font-size:.78rem}.form-actions{justify-content:flex-end;gap:.6rem;display:flex}.inline-actions{gap:.6rem;margin:1rem 0;display:flex}.filter-form{grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;align-items:end;gap:.8rem;margin:1rem 0;display:grid}.field-label{font-size:.78rem;font-weight:650}.control{border:1px solid var(--border-strong);background:var(--panel);width:100%;min-height:2.45rem;color:var(--ink);border-radius:.5rem;padding:.55rem .65rem}.control[aria-invalid=true]{border-color:var(--danger)}.field-error{color:var(--danger);margin:0;font-size:.78rem}.checkbox{align-items:center;gap:.5rem;display:flex}.badge{border:1px solid var(--border);border-radius:999px;align-items:center;gap:.32rem;padding:.15rem .45rem;font-size:.72rem;font-weight:700;display:inline-flex}.badge:before{content:"";background:currentColor;border-radius:50%;width:.38rem;height:.38rem}.badge-success{color:var(--success)}.badge-warning{color:var(--warning)}.badge-danger{color:var(--danger)}.alert{border:1px solid var(--border);border-radius:.6rem;margin:.75rem 0;padding:.75rem .9rem}.alert-error{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in srgb, var(--danger), transparent 60%)}}.alert-error{color:var(--danger)}.empty-state{text-align:center;padding:2.5rem 1rem}.empty-state p{color:var(--muted);margin:.3rem auto 0}.table-scroll{max-width:100%;overflow-x:auto}.data-table{border-collapse:collapse;width:100%;min-width:42rem}.data-table th{background:var(--panel-muted);color:var(--muted);letter-spacing:.06em;text-align:left;text-transform:uppercase;font-size:.69rem}.data-table th,.data-table td{border-bottom:1px solid var(--border);padding:.7rem 1rem}.data-table tr:last-child td{border-bottom:0}.tabs{border:1px solid var(--border);background:var(--panel-muted);border-radius:.55rem;gap:.2rem;padding:.2rem;display:inline-flex}.tab{border-radius:.4rem;padding:.4rem .65rem;text-decoration:none}.tab[aria-current=page]{background:var(--panel);box-shadow:0 1px 2px #14192314}.pagination{justify-content:flex-end;align-items:center;gap:.4rem;padding:.8rem 1rem;display:flex}.skeleton{background:var(--panel-muted);border-radius:.35rem;height:1rem;animation:1.5s ease-in-out infinite pulse}.notification-menu{min-width:18rem;padding:.75rem}dialog{border:1px solid var(--border);background:var(--panel);max-width:30rem;color:var(--ink);border-radius:.9rem;padding:1.25rem}dialog::backdrop{background:#00000073}@keyframes pulse{50%{opacity:.45}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;animation-duration:.01ms!important}}}@layer utilities{.collapse{visibility:collapse}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.fixed{position:fixed}.relative{position:relative}.static{position:static}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.block{display:block}.hidden{display:none}.inline{display:inline}.table{display:table}.border{border-style:var(--tw-border-style);border-width:1px}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}}:root{color-scheme:light;--ink:#15171a;--canvas:#f6f7f8;--sidebar:#f0f1f2;--panel:#fff;--panel-muted:#f7f8f9;--border:#858c96;--border-strong:#747d89;--muted:#626a75;--subtle:#626a75;--accent:#3157e7;--focus:#3157e7;--success:#087a48;--warning:#7a4d00;--danger:#b42318}:root[data-theme=dark]{color-scheme:dark;--ink:#eceef1;--canvas:#0b0c0e;--sidebar:#111317;--panel:#17191e;--panel-muted:#1d2026;--border:#626b78;--border-strong:#707987;--muted:#b2b7c0;--subtle:#969da8;--accent:#8ca4ff;--focus:#9eb2ff;--success:#4fd19b;--warning:#f0bc63;--danger:#ff8e88}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file diff --git a/src/main/resources/static/assets/app.js b/src/main/resources/static/assets/app.js index 649cad1..b443b1b 100644 --- a/src/main/resources/static/assets/app.js +++ b/src/main/resources/static/assets/app.js @@ -25,10 +25,15 @@ document.addEventListener('DOMContentLoaded', () => { let collapsed = false; try { collapsed = localStorage.getItem('labtimesheet-sidebar') === 'collapsed'; } catch (_) { /* Use the expanded default. */ } - root.dataset.sidebarCollapsed = String(collapsed); - document.querySelector('[data-sidebar-toggle]')?.addEventListener('click', () => { - collapsed = !collapsed; + const sidebarToggle = document.querySelector('[data-sidebar-toggle]'); + const applySidebarState = () => { root.dataset.sidebarCollapsed = String(collapsed); + sidebarToggle?.setAttribute('aria-expanded', String(!collapsed)); + }; + applySidebarState(); + sidebarToggle?.addEventListener('click', () => { + collapsed = !collapsed; + applySidebarState(); try { localStorage.setItem('labtimesheet-sidebar', collapsed ? 'collapsed' : 'expanded'); } catch (_) { /* Collapse still works for this page. */ } }); diff --git a/src/main/resources/templates/fragments/auth-layout.html b/src/main/resources/templates/fragments/auth-layout.html index 711bec1..5aa161d 100644 --- a/src/main/resources/templates/fragments/auth-layout.html +++ b/src/main/resources/templates/fragments/auth-layout.html @@ -5,6 +5,7 @@ Lab Timesheet + diff --git a/src/main/resources/templates/fragments/layout.html b/src/main/resources/templates/fragments/layout.html index 3e46c19..4de10be 100644 --- a/src/main/resources/templates/fragments/layout.html +++ b/src/main/resources/templates/fragments/layout.html @@ -5,6 +5,7 @@ Lab Timesheet + @@ -19,31 +20,31 @@
- +
Section / Page
diff --git a/src/main/resources/templates/smtp/defer.html b/src/main/resources/templates/smtp/defer.html index 9f38ab8..17abefa 100644 --- a/src/main/resources/templates/smtp/defer.html +++ b/src/main/resources/templates/smtp/defer.html @@ -1,6 +1,6 @@ -Defer SMTP configuration +Defer SMTP configuration

Defer SMTP configuration

diff --git a/src/main/resources/templates/smtp/form.html b/src/main/resources/templates/smtp/form.html index 6b25946..ddb16c8 100644 --- a/src/main/resources/templates/smtp/form.html +++ b/src/main/resources/templates/smtp/form.html @@ -1,6 +1,6 @@ -SMTP configuration +SMTP configuration

SMTP configuration

diff --git a/src/test/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceControllerTest.java b/src/test/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceControllerTest.java index 72cd1bb..600ba77 100644 --- a/src/test/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceControllerTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceControllerTest.java @@ -26,6 +26,7 @@ import com.lab.labtimesheet.feature.attendance.model.dto.GlobalCalendarEvent; import com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationService; import com.lab.labtimesheet.feature.attendance.service.AttendanceCurrentUserService; import com.lab.labtimesheet.feature.attendance.service.CalendarApplicationService; +import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService; import java.time.Instant; import java.time.LocalDate; import java.util.List; @@ -50,6 +51,9 @@ class AttendanceControllerTest { @MockitoBean private AttendanceCurrentUserService currentUsers; + @MockitoBean + private SmtpConfigurationService smtpConfiguration; + @Test void internPunchesOnlyForAuthenticatedSelf() throws Exception { AttendanceActor actor = new AttendanceActor(42L, AttendanceRole.INTERN); diff --git a/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java b/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java index 59f100d..3cfc1e7 100644 --- a/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java @@ -25,6 +25,7 @@ import com.lab.labtimesheet.feature.project.model.dto.ProjectLeadershipTermView; import com.lab.labtimesheet.feature.project.model.dto.ProjectMemberView; import com.lab.labtimesheet.feature.project.service.ProjectQueryService; import com.lab.labtimesheet.feature.project.service.ProjectService; +import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService; import java.time.Instant; import java.time.LocalDate; import java.util.List; @@ -49,6 +50,9 @@ class ProjectControllerTest { @MockitoBean private ProjectService projects; + @MockitoBean + private SmtpConfigurationService smtpConfiguration; + @Test @WithMockUser(username = "mentor@example.test") void listsOnlyTheAuthenticatedUsersAuthorizedProjects() throws Exception { diff --git a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/AccountTemplateIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/AccountTemplateIntegrationTest.java index 87864d2..72fd0d6 100644 --- a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/AccountTemplateIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/AccountTemplateIntegrationTest.java @@ -6,11 +6,16 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.lab.labtimesheet.feature.account.model.dto.ActivationForm; +import com.lab.labtimesheet.feature.account.model.dto.BootstrapForm; +import com.lab.labtimesheet.feature.account.model.dto.CreateAccountForm; +import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.context.annotation.Import; import org.springframework.stereotype.Controller; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -21,6 +26,9 @@ class AccountTemplateIntegrationTest { private final MockMvc mvc; + @MockitoBean + private SmtpConfigurationService smtpConfiguration; + @Autowired AccountTemplateIntegrationTest(MockMvc mvc) { this.mvc = mvc; @@ -80,13 +88,16 @@ class AccountTemplateIntegrationTest { public static class TemplateController { @GetMapping("/template-contract/accounts/new") - String accountCreation() { + String accountCreation(Model model) { + model.addAttribute("accountForm", new CreateAccountForm()); return "accounts/new"; } @GetMapping("/template-contract/accounts/activate") String activation(Model model) { - model.addAttribute("token", "raw-token"); + ActivationForm form = new ActivationForm(); + form.setToken("raw-token"); + model.addAttribute("activationForm", form); return "accounts/activate"; } @@ -96,7 +107,8 @@ class AccountTemplateIntegrationTest { } @GetMapping("/template-contract/bootstrap") - String bootstrap() { + String bootstrap(Model model) { + model.addAttribute("bootstrapForm", new BootstrapForm()); return "bootstrap/form"; } } diff --git a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/AttendanceTemplateIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/AttendanceTemplateIntegrationTest.java index e3566e1..e1b9c59 100644 --- a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/AttendanceTemplateIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/AttendanceTemplateIntegrationTest.java @@ -6,9 +6,10 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy; +import com.lab.labtimesheet.feature.attendance.model.AttendancePolicyFixtures; import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations; import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem; +import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService; import java.time.Instant; import java.time.LocalDate; import java.util.List; @@ -17,6 +18,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.context.annotation.Import; import org.springframework.stereotype.Controller; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -27,6 +29,9 @@ class AttendanceTemplateIntegrationTest { private final MockMvc mvc; + @MockitoBean + private SmtpConfigurationService smtpConfiguration; + @Autowired AttendanceTemplateIntegrationTest(MockMvc mvc) { this.mvc = mvc; @@ -91,13 +96,13 @@ class AttendanceTemplateIntegrationTest { LocalDate.of(2026, 8, 14), Instant.parse("2026-08-14T02:05:00Z"), Instant.parse("2026-08-14T09:00:00Z"), - AttendancePolicy.seeded(1L), + AttendancePolicyFixtures.seeded(1L), new AttendanceViolations(true, true, false)), new AttendanceHistoryItem( LocalDate.of(2026, 8, 13), Instant.parse("2026-08-13T01:30:00Z"), null, - AttendancePolicy.seeded(1L), + AttendancePolicyFixtures.seeded(1L), new AttendanceViolations(true, false, true)))); return "attendance/history"; } diff --git a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/DashboardControllerWebTest.java b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/DashboardControllerWebTest.java index 7df5aac..bc5176c 100644 --- a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/DashboardControllerWebTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/DashboardControllerWebTest.java @@ -9,6 +9,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; +import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService; import com.lab.labtimesheet.feature.reporting.model.dto.DashboardView; import com.lab.labtimesheet.feature.reporting.service.DashboardService; import java.util.List; @@ -27,6 +28,9 @@ class DashboardControllerWebTest { @MockitoBean private DashboardService dashboards; + @MockitoBean + private SmtpConfigurationService smtpConfiguration; + @Test void adminRendersAdminDashboardForAuthenticatedIdentity() throws Exception { var dashboard = new DashboardView.Admin(2, 1, 1, 3); diff --git a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/DashboardTemplateWebTest.java b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/DashboardTemplateWebTest.java index a5282d6..85cf24a 100644 --- a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/DashboardTemplateWebTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/DashboardTemplateWebTest.java @@ -6,6 +6,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService; import com.lab.labtimesheet.feature.reporting.model.dto.DashboardView; import java.time.LocalDate; import java.util.List; @@ -15,6 +16,7 @@ import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.context.annotation.Import; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.stereotype.Controller; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -25,6 +27,9 @@ class DashboardTemplateWebTest { private final MockMvc mvc; + @MockitoBean + private SmtpConfigurationService smtpConfiguration; + @Autowired DashboardTemplateWebTest(MockMvc mvc) { this.mvc = mvc; diff --git a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/ProjectTaskFormAccessibilityWebTest.java b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/ProjectTaskFormAccessibilityWebTest.java index c07381c..97d40fb 100644 --- a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/ProjectTaskFormAccessibilityWebTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/ProjectTaskFormAccessibilityWebTest.java @@ -8,6 +8,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService; import com.lab.labtimesheet.feature.project.controller.ProjectController; import com.lab.labtimesheet.feature.project.service.ProjectQueryService; import com.lab.labtimesheet.feature.project.service.ProjectService; @@ -36,6 +37,9 @@ class ProjectTaskFormAccessibilityWebTest { @MockitoBean private TaskService tasks; + @MockitoBean + private SmtpConfigurationService smtpConfiguration; + @Test void projectFieldErrorsHaveStableIdsAndInputAssociations() throws Exception { mvc.perform(post("/projects") diff --git a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/SharedErrorTemplateWebTest.java b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/SharedErrorTemplateWebTest.java index 86c0468..75e32c3 100644 --- a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/SharedErrorTemplateWebTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/SharedErrorTemplateWebTest.java @@ -6,6 +6,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; @@ -13,6 +14,7 @@ import org.springframework.context.annotation.Import; import org.springframework.http.HttpStatus; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.stereotype.Controller; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -25,6 +27,9 @@ class SharedErrorTemplateWebTest { @Autowired private MockMvc mvc; + @MockitoBean + private SmtpConfigurationService smtpConfiguration; + @Test @WithMockUser(username = "intern@example.test", roles = "INTERN") void notFoundPageUsesSharedShellWithoutDisclosingRecordDetails() throws Exception { diff --git a/src/test/java/com/lab/labtimesheet/feature/task/controller/TaskControllerTest.java b/src/test/java/com/lab/labtimesheet/feature/task/controller/TaskControllerTest.java index 023d1e3..6311a04 100644 --- a/src/test/java/com/lab/labtimesheet/feature/task/controller/TaskControllerTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/task/controller/TaskControllerTest.java @@ -26,6 +26,7 @@ import com.lab.labtimesheet.feature.task.model.dto.TaskDetails; import com.lab.labtimesheet.feature.task.model.dto.TaskListView; import com.lab.labtimesheet.feature.task.model.dto.TaskView; import com.lab.labtimesheet.feature.task.service.TaskService; +import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService; import java.time.Instant; import java.time.LocalDate; import java.util.List; @@ -51,6 +52,9 @@ class TaskControllerTest { @MockitoBean private TaskService taskService; + @MockitoBean + private SmtpConfigurationService smtpConfiguration; + @Test void taskListRequiresAuthentication() throws Exception { mockMvc.perform(get("/projects/10/tasks")) diff --git a/src/test/java/com/lab/labtimesheet/ui/UiContractWebTest.java b/src/test/java/com/lab/labtimesheet/ui/UiContractWebTest.java index 876bea8..9e2c61e 100644 --- a/src/test/java/com/lab/labtimesheet/ui/UiContractWebTest.java +++ b/src/test/java/com/lab/labtimesheet/ui/UiContractWebTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService; import java.nio.charset.StandardCharsets; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -15,6 +16,7 @@ import org.springframework.core.io.ClassPathResource; import org.springframework.context.annotation.Import; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.stereotype.Controller; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.web.bind.annotation.GetMapping; @@ -25,6 +27,9 @@ class UiContractWebTest { private final MockMvc mvc; + @MockitoBean + private SmtpConfigurationService smtpConfiguration; + @Autowired UiContractWebTest(MockMvc mvc) { this.mvc = mvc; @@ -49,6 +54,7 @@ class UiContractWebTest { assertFalse(html.contains("href=\"/profile\"")); assertFalse(html.contains("href=\"/notifications\"")); assertTrue(html.indexOf("/assets/theme.js") < html.indexOf("/assets/app.css")); + assertTrue(html.contains("rel=\"icon\" href=\"/assets/icons.svg\"")); assertTrue(html.contains("href=\"/assets/icons.svg#panel-left\"")); } @@ -91,6 +97,29 @@ class UiContractWebTest { assertTrue(themeBootstrap.contains("matchMedia('(prefers-color-scheme: dark)')")); } + @Test + @WithMockUser(username = "admin@example.test", roles = "ADMIN") + void collapsedSidebarExposesStateAndKeyboardVisibleControlNames() throws Exception { + String html = mvc.perform(get("/ui-contract")) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(StandardCharsets.UTF_8); + String script = new ClassPathResource("static/assets/app.js") + .getContentAsString(StandardCharsets.UTF_8); + String css = new ClassPathResource("static/assets/app.css") + .getContentAsString(StandardCharsets.UTF_8); + + assertTrue(html.contains("data-sidebar-toggle aria-expanded=\"true\"")); + assertTrue(html.contains("data-tooltip=\"Overview\"")); + assertTrue(html.contains("data-tooltip=\"Accounts\"")); + assertTrue(html.contains("data-tooltip=\"Global calendar\"")); + assertTrue(html.contains("data-tooltip=\"Logout\"")); + assertTrue(html.contains("title=\"Theme preference\"")); + assertTrue(script.contains("setAttribute('aria-expanded', String(!collapsed))")); + assertTrue(css.contains("content:attr(data-tooltip)")); + } + @Test void themeTokensMeetTextFocusAndMeaningfulBoundaryContrast() throws Exception { String css = new ClassPathResource("static/assets/app.css") diff --git a/src/test/resources/templates/error/generic.html b/src/test/resources/templates/error/generic.html deleted file mode 100644 index 1ea6437..0000000 --- a/src/test/resources/templates/error/generic.html +++ /dev/null @@ -1,10 +0,0 @@ - - -Request unavailable - -
-

Request unavailable

-

The request could not be completed.

-
- -