fix(ui): complete reviewed desktop integration

This commit is contained in:
sechmachine
2026-08-15 04:03:19 +07:00
parent 1f6454ae53
commit c81c0dfe65
19 changed files with 148 additions and 43 deletions
+20
View File
@@ -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; }
File diff suppressed because one or more lines are too long
+8 -3
View File
@@ -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. */ }
});
@@ -5,6 +5,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title th:text="${pageTitle + ' · Lab Timesheet'}">Lab Timesheet</title>
<link rel="icon" th:href="@{/assets/icons.svg}" type="image/svg+xml">
<script th:src="@{/assets/theme.js}"></script>
<link rel="stylesheet" th:href="@{/assets/app.css}">
<script defer th:src="@{/assets/app.js}"></script>
@@ -5,6 +5,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title th:text="${pageTitle + ' · Lab Timesheet'}">Lab Timesheet</title>
<link rel="icon" th:href="@{/assets/icons.svg}" type="image/svg+xml">
<script th:src="@{/assets/theme.js}"></script>
<link rel="stylesheet" th:href="@{/assets/app.css}">
<script defer th:src="@{/assets/app.js}"></script>
@@ -19,31 +20,31 @@
<nav>
<p class="nav-label sidebar-label">Workspace</p>
<ul class="nav-list">
<li><a class="nav-link" th:href="@{/dashboard}" th:attr="aria-current=${activeNav == 'dashboard'} ? 'page' : null">
<li><a class="nav-link" th:href="@{/dashboard}" data-tooltip="Overview" th:attr="aria-current=${activeNav == 'dashboard'} ? 'page' : null">
<svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#layout-dashboard}"></use></svg><span class="sidebar-label">Overview</span></a></li>
<li sec:authorize="hasRole('ADMIN')"><a class="nav-link" th:href="@{/admin/accounts/new}" th:attr="aria-current=${activeNav == 'accounts'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#users}"></use></svg><span class="sidebar-label">Accounts</span></a></li>
<li sec:authorize="hasRole('ADMIN')"><a class="nav-link" th:href="@{/attendance/calendar}" th:attr="aria-current=${activeNav == 'calendar'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#calendar-days}"></use></svg><span class="sidebar-label">Global calendar</span></a></li>
<li sec:authorize="hasRole('MENTOR')"><a class="nav-link" th:href="@{/projects}" th:attr="aria-current=${activeNav == 'projects'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#folder-kanban}"></use></svg><span class="sidebar-label">Owned Projects</span></a></li>
<li sec:authorize="hasRole('INTERN')"><a class="nav-link" th:href="@{/attendance}" th:attr="aria-current=${activeNav == 'attendance'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#clock}"></use></svg><span class="sidebar-label">My attendance</span></a></li>
<li sec:authorize="hasRole('INTERN')"><a class="nav-link" th:href="@{/projects}" th:attr="aria-current=${activeNav == 'projects'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#folder-kanban}"></use></svg><span class="sidebar-label">My Projects</span></a></li>
<li sec:authorize="hasRole('ADMIN')"><a class="nav-link" th:href="@{/admin/accounts/new}" data-tooltip="Accounts" th:attr="aria-current=${activeNav == 'accounts'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#users}"></use></svg><span class="sidebar-label">Accounts</span></a></li>
<li sec:authorize="hasRole('ADMIN')"><a class="nav-link" th:href="@{/attendance/calendar}" data-tooltip="Global calendar" th:attr="aria-current=${activeNav == 'calendar'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#calendar-days}"></use></svg><span class="sidebar-label">Global calendar</span></a></li>
<li sec:authorize="hasRole('MENTOR')"><a class="nav-link" th:href="@{/projects}" data-tooltip="Owned Projects" th:attr="aria-current=${activeNav == 'projects'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#folder-kanban}"></use></svg><span class="sidebar-label">Owned Projects</span></a></li>
<li sec:authorize="hasRole('INTERN')"><a class="nav-link" th:href="@{/attendance}" data-tooltip="My attendance" th:attr="aria-current=${activeNav == 'attendance'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#clock}"></use></svg><span class="sidebar-label">My attendance</span></a></li>
<li sec:authorize="hasRole('INTERN')"><a class="nav-link" th:href="@{/projects}" data-tooltip="My Projects" th:attr="aria-current=${activeNav == 'projects'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#folder-kanban}"></use></svg><span class="sidebar-label">My Projects</span></a></li>
</ul>
</nav>
<div class="sidebar-footer">
<div class="theme-field">
<label class="field-label sidebar-label" for="theme-preference">Theme</label>
<select id="theme-preference" data-theme-select aria-label="Theme">
<select id="theme-preference" data-theme-select aria-label="Theme" title="Theme preference">
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
</div>
<div class="account"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#circle-user-round}"></use></svg><span class="sidebar-label" sec:authentication="name">Account</span></div>
<form class="logout-form" th:action="@{/logout}" method="post"><button class="nav-link" type="submit"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#log-out}"></use></svg><span class="sidebar-label">Logout</span></button></form>
<form class="logout-form" th:action="@{/logout}" method="post"><button class="nav-link" type="submit" data-tooltip="Logout"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#log-out}"></use></svg><span class="sidebar-label">Logout</span></button></form>
</div>
</aside>
<div class="app-column">
<header class="app-header">
<button class="icon-button" type="button" data-sidebar-toggle aria-label="Toggle sidebar" title="Toggle sidebar"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#panel-left}"></use></svg></button>
<button class="icon-button" type="button" data-sidebar-toggle aria-expanded="true" aria-label="Toggle sidebar" title="Toggle sidebar"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#panel-left}"></use></svg></button>
<div class="header-title"><span class="breadcrumb" th:text="${section}">Section</span> / <span th:text="${pageTitle}">Page</span></div>
</header>
<div class="page">
+1 -1
View File
@@ -1,6 +1,6 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title>Defer SMTP configuration</title></head>
<head><meta charset="utf-8"><title>Defer SMTP configuration</title><link rel="icon" th:href="@{/assets/icons.svg}" type="image/svg+xml"></head>
<body>
<main>
<h1>Defer SMTP configuration</h1>
+1 -1
View File
@@ -1,6 +1,6 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title>SMTP configuration</title></head>
<head><meta charset="utf-8"><title>SMTP configuration</title><link rel="icon" th:href="@{/assets/icons.svg}" type="image/svg+xml"></head>
<body>
<main>
<h1>SMTP configuration</h1>
@@ -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);
@@ -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 {
@@ -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";
}
}
@@ -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";
}
@@ -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);
@@ -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;
@@ -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")
@@ -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 {
@@ -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"))
@@ -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")
@@ -1,10 +0,0 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title th:text="${errorTitle}">Request unavailable</title></head>
<body>
<main>
<h1 th:text="${errorTitle}">Request unavailable</h1>
<p th:text="${errorMessage}">The request could not be completed.</p>
</main>
</body>
</html>