feat(ui): establish shared desktop shell and assets
This commit is contained in:
@@ -28,6 +28,7 @@ target/
|
||||
build/
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
node_modules/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Test Evidence: shared UI shell and components
|
||||
|
||||
- **Test type:** Web
|
||||
- **Requirement IDs:** `ARC-004`, `UI-001`–`UI-010`, `UI-013`–`UI-018`, `I1-UI-01`, `I1-UI-02`, `I1-UI-04`
|
||||
- **Scenario IDs:** `AC-UI-001`, `AC-UI-002`, `AC-UI-003`, `AC-UI-005`
|
||||
- **Test class/method:** `com.lab.labtimesheet.ui.UiContractWebTest`
|
||||
- **Implementation commit:** `pending`
|
||||
|
||||
## Protected behavior
|
||||
|
||||
Domain-owned Thymeleaf pages can render inside one desktop shell with role-filtered navigation, accessible controls/states, pre-paint local theme loading, and committed local CSS/JavaScript/Lucide assets. The tests catch missing fragments, unauthorized navigation leakage, inaccessible shared form/status markup, remote icon references, or a theme bootstrap loaded after CSS.
|
||||
|
||||
## Test method
|
||||
|
||||
A test-only domain page consumes the production layout fragment through MockMvc with a real Spring Security principal. A second page renders representative production fragments. The asset test reads the committed classpath artifacts produced by the pinned Node build.
|
||||
|
||||
## Hand-derived expected result
|
||||
|
||||
A Mentor sees `Owned Projects`, theme, profile identity, and logout, but not Admin `Accounts` or Intern `My attendance`. The theme script occurs before the stylesheet. Form label/control IDs match, errors use `role="alert"`, status includes a textual accessible name, confirmation copy is described, and the reduced sprite contains the selected symbols without remote resource references.
|
||||
|
||||
## RED
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
|
||||
./mvnw -Dtest=UiContractWebTest test
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
Tests run: 2, Failures: 1, Errors: 1, Skipped: 0
|
||||
UiContractWebTest.compiledAssetsAreLocalAndContainOnlyTheSelectedIconSprite expected: <true> but was: <false>
|
||||
UiContractWebTest.sharedShellRendersAuthorizedDesktopNavigationBeforeDomainPagesIntegrate: Request processing failed: Error resolving template [fragments/layout]
|
||||
BUILD FAILURE
|
||||
```
|
||||
|
||||
The asset assertion failed because the committed build artifacts did not exist, and the rendering request reached the test controller but could not resolve the missing production layout.
|
||||
|
||||
## GREEN
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
|
||||
./mvnw -Dtest=UiContractWebTest test
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
|
||||
BUILD SUCCESS
|
||||
Total time: 4.514 s
|
||||
```
|
||||
|
||||
## Affected suite
|
||||
|
||||
**Command and result**
|
||||
|
||||
```text
|
||||
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 ci
|
||||
npm run build
|
||||
git diff --exit-code -- src/main/resources/static/assets/app.css src/main/resources/static/assets/icons.svg
|
||||
./mvnw test
|
||||
|
||||
added 34 packages, audited 35 packages, found 0 vulnerabilities
|
||||
Tailwind CSS v4.3.3: Done in 45ms
|
||||
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
|
||||
BUILD SUCCESS
|
||||
Total time: 7.697 s
|
||||
```
|
||||
|
||||
## External-test boundaries
|
||||
|
||||
The tests prove server-rendered authorization-aware markup and reproducible local assets. They do not replace manual browser checks for zero-flash paint timing, measured WCAG contrast, keyboard tooltip behavior, or page-level overflow at 1365×900; those remain final integrated UI gates.
|
||||
Generated
+1248
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "labtimesheet-ui",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": "24.x",
|
||||
"npm": "11.x"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run build:css && npm run build:icons",
|
||||
"build:css": "tailwindcss -i src/main/frontend/app.css -o src/main/resources/static/assets/app.css --minify",
|
||||
"build:icons": "node src/main/frontend/build-icons.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "4.3.3",
|
||||
"lucide-static": "1.27.0",
|
||||
"tailwindcss": "4.3.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
@import "tailwindcss";
|
||||
@source "../resources/templates/**/*.html";
|
||||
|
||||
@theme {
|
||||
--color-ink: #15171a;
|
||||
--color-canvas: #f6f7f8;
|
||||
--color-sidebar: #f0f1f2;
|
||||
--color-panel: #ffffff;
|
||||
--color-panel-muted: #f7f8f9;
|
||||
--color-border: #dfe1e5;
|
||||
--color-border-strong: #c9cdd3;
|
||||
--color-muted: #626a75;
|
||||
--color-accent: #3157e7;
|
||||
--color-success: #087a48;
|
||||
--color-warning: #996000;
|
||||
--color-danger: #b42318;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--ink: #15171a;
|
||||
--canvas: #f6f7f8;
|
||||
--sidebar: #f0f1f2;
|
||||
--panel: #ffffff;
|
||||
--panel-muted: #f7f8f9;
|
||||
--border: #dfe1e5;
|
||||
--border-strong: #c9cdd3;
|
||||
--muted: #626a75;
|
||||
--subtle: #818894;
|
||||
--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: #30343d;
|
||||
--border-strong: #454b57;
|
||||
--muted: #b2b7c0;
|
||||
--subtle: #969da8;
|
||||
--accent: #8ca4ff;
|
||||
--focus: #9eb2ff;
|
||||
--success: #4fd19b;
|
||||
--warning: #f0bc63;
|
||||
--danger: #ff8e88;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* { box-sizing: border-box; }
|
||||
html { min-width: 64rem; background: var(--canvas); }
|
||||
body { margin: 0; overflow-x: hidden; background: var(--canvas); color: var(--ink); font: 14px/1.45 ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||
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 { display: grid; grid-template-columns: 16rem minmax(0, 1fr); min-height: 100vh; }
|
||||
[data-sidebar-collapsed="true"] .app-shell { grid-template-columns: 4rem minmax(0, 1fr); }
|
||||
.sidebar { position: sticky; top: 0; display: flex; height: 100vh; flex-direction: column; border-right: 1px solid var(--border); background: var(--sidebar); padding: 1rem .75rem; }
|
||||
.brand, .account { display: flex; align-items: center; gap: .7rem; min-width: 0; padding: .25rem .4rem; }
|
||||
.brand-mark { display: grid; width: 2rem; height: 2rem; flex: 0 0 auto; place-items: center; border-radius: .55rem; background: var(--ink); color: var(--panel); }
|
||||
.sidebar-label { overflow: hidden; white-space: nowrap; }
|
||||
[data-sidebar-collapsed="true"] .sidebar-label { width: 0; opacity: 0; }
|
||||
.nav-label { margin: 1.6rem .6rem .4rem; color: var(--subtle); font-size: .68rem; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.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-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; }
|
||||
.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; }
|
||||
.header-title { min-width: 0; font-weight: 700; }
|
||||
.breadcrumb { color: var(--muted); font-weight: 400; }
|
||||
.header-actions { display: flex; align-items: center; gap: .55rem; margin-left: auto; }
|
||||
.icon-button { display: inline-grid; width: 2.5rem; height: 2.5rem; place-items: center; border: 1px solid var(--border-strong); border-radius: .5rem; background: var(--panel); color: var(--ink); cursor: pointer; }
|
||||
.page { min-width: 0; padding: 1.55rem; }
|
||||
.page-heading { display: flex; align-items: end; gap: 1rem; margin-bottom: 1.1rem; }
|
||||
.page-heading-copy { min-width: 0; }
|
||||
.page-title { margin: 0; font-size: 1.56rem; line-height: 1.2; letter-spacing: -.025em; }
|
||||
.page-description { max-width: 72ch; margin: .3rem 0 0; color: var(--muted); }
|
||||
.primary-action { margin-left: auto; }
|
||||
.button { display: inline-flex; min-height: 2.35rem; align-items: center; justify-content: center; gap: .45rem; border: 1px solid var(--border-strong); border-radius: .5rem; padding: .5rem .8rem; background: var(--panel); color: var(--ink); font-weight: 650; text-decoration: none; cursor: pointer; }
|
||||
.button-primary { border-color: var(--ink); background: var(--ink); color: var(--panel); }
|
||||
.button-danger { border-color: color-mix(in srgb, var(--danger), transparent 65%); background: color-mix(in srgb, var(--danger), transparent 90%); color: var(--danger); }
|
||||
.panel { border: 1px solid var(--border); border-radius: .75rem; background: var(--panel); box-shadow: 0 10px 28px rgb(20 25 35 / .06); }
|
||||
.panel-header { padding: .9rem 1rem; border-bottom: 1px solid var(--border); }
|
||||
.panel-title { margin: 0; font-size: 1rem; }
|
||||
.metric-strip { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); overflow: hidden; margin-bottom: 1rem; }
|
||||
.metric { min-width: 0; padding: 1rem; }
|
||||
.metric + .metric { border-left: 1px solid var(--border); }
|
||||
.metric-label { color: var(--muted); font-size: .78rem; }
|
||||
.metric-value { margin-top: .35rem; font-size: 1.4rem; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.metric-detail { margin-top: .18rem; color: var(--muted); font-size: .78rem; }
|
||||
.field { display: grid; gap: .35rem; }
|
||||
.field-label { font-size: .78rem; font-weight: 650; }
|
||||
.control { min-height: 2.45rem; width: 100%; border: 1px solid var(--border-strong); border-radius: .5rem; background: var(--panel); color: var(--ink); padding: .55rem .65rem; }
|
||||
.control[aria-invalid="true"] { border-color: var(--danger); }
|
||||
.field-error { margin: 0; color: var(--danger); font-size: .78rem; }
|
||||
.checkbox { display: flex; align-items: center; gap: .5rem; }
|
||||
.badge { display: inline-flex; align-items: center; gap: .32rem; border: 1px solid var(--border); border-radius: 999px; padding: .15rem .45rem; font-size: .72rem; font-weight: 700; }
|
||||
.badge::before { content: ""; width: .38rem; height: .38rem; border-radius: 50%; background: currentColor; }
|
||||
.badge-success { color: var(--success); }
|
||||
.badge-warning { color: var(--warning); }
|
||||
.badge-danger { color: var(--danger); }
|
||||
.alert { margin: .75rem 0; border: 1px solid var(--border); border-radius: .6rem; padding: .75rem .9rem; }
|
||||
.alert-error { border-color: color-mix(in srgb, var(--danger), transparent 60%); color: var(--danger); }
|
||||
.empty-state { padding: 2.5rem 1rem; text-align: center; }
|
||||
.empty-state p { margin: .3rem auto 0; color: var(--muted); }
|
||||
.table-scroll { max-width: 100%; overflow-x: auto; }
|
||||
.data-table { width: 100%; min-width: 42rem; border-collapse: collapse; }
|
||||
.data-table th { background: var(--panel-muted); color: var(--muted); font-size: .69rem; letter-spacing: .06em; text-align: left; text-transform: uppercase; }
|
||||
.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 { display: inline-flex; gap: .2rem; border: 1px solid var(--border); border-radius: .55rem; background: var(--panel-muted); padding: .2rem; }
|
||||
.tab { border-radius: .4rem; padding: .4rem .65rem; text-decoration: none; }
|
||||
.tab[aria-current="page"] { background: var(--panel); box-shadow: 0 1px 2px rgb(20 25 35 / .08); }
|
||||
.pagination { display: flex; align-items: center; justify-content: flex-end; gap: .4rem; padding: .8rem 1rem; }
|
||||
.skeleton { height: 1rem; border-radius: .35rem; background: var(--panel-muted); animation: pulse 1.5s ease-in-out infinite; }
|
||||
.notification-menu { min-width: 18rem; padding: .75rem; }
|
||||
dialog { max-width: 30rem; border: 1px solid var(--border); border-radius: .9rem; background: var(--panel); color: var(--ink); padding: 1.25rem; }
|
||||
dialog::backdrop { background: rgb(0 0 0 / .45); }
|
||||
@keyframes pulse { 50% { opacity: .45; } }
|
||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; } }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const names = [
|
||||
'bell', 'calendar-days', 'check-circle-2', 'chevron-left', 'chevron-right',
|
||||
'circle-user-round', 'clock', 'folder-kanban', 'folder-open', 'inbox',
|
||||
'layout-dashboard', 'list-check', 'log-out', 'monitor', 'moon', 'panel-left',
|
||||
'settings', 'sun', 'triangle-alert', 'users', 'x'
|
||||
];
|
||||
const output = resolve('src/main/resources/static/assets/icons.svg');
|
||||
const symbols = await Promise.all(names.map(async (name) => {
|
||||
const svg = await readFile(resolve(`node_modules/lucide-static/icons/${name}.svg`), 'utf8');
|
||||
const viewBox = svg.match(/viewBox="([^"]+)"/)?.[1] ?? '0 0 24 24';
|
||||
const body = svg.match(/<svg[\s\S]*?>([\s\S]*?)<\/svg>/)?.[1];
|
||||
if (!body) throw new Error(`Invalid Lucide SVG: ${name}`);
|
||||
return `<symbol id="${name}" viewBox="${viewBox}">${body.trim()}</symbol>`;
|
||||
}));
|
||||
|
||||
await mkdir(dirname(output), { recursive: true });
|
||||
await writeFile(output, `<svg xmlns="http://www.w3.org/2000/svg" style="display:none">${symbols.join('')}</svg>\n`);
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,35 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const root = document.documentElement;
|
||||
const theme = document.querySelector('[data-theme-select]');
|
||||
const stored = (() => {
|
||||
try { return localStorage.getItem('labtimesheet-theme') || 'system'; }
|
||||
catch (_) { return 'system'; }
|
||||
})();
|
||||
if (theme) {
|
||||
theme.value = stored;
|
||||
theme.addEventListener('change', () => {
|
||||
try {
|
||||
theme.value === 'system'
|
||||
? localStorage.removeItem('labtimesheet-theme')
|
||||
: localStorage.setItem('labtimesheet-theme', theme.value);
|
||||
} catch (_) {
|
||||
// Theme still applies for this page when persistence is unavailable.
|
||||
}
|
||||
const dark = theme.value === 'dark'
|
||||
|| (theme.value === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
root.dataset.theme = dark ? 'dark' : 'light';
|
||||
root.style.colorScheme = dark ? 'dark' : 'light';
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
root.dataset.sidebarCollapsed = String(collapsed);
|
||||
try { localStorage.setItem('labtimesheet-sidebar', collapsed ? 'collapsed' : 'expanded'); }
|
||||
catch (_) { /* Collapse still works for this page. */ }
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none"><symbol id="bell" viewBox="0 0 24 24"><path d="M10.268 21a2 2 0 0 0 3.464 0" />
|
||||
<path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol><symbol id="calendar-days" viewBox="0 0 24 24"><path d="M8 2v4" />
|
||||
<path d="M16 2v4" />
|
||||
<rect width="18" height="18" x="3" y="4" rx="2" />
|
||||
<path d="M3 10h18" />
|
||||
<path d="M8 14h.01" />
|
||||
<path d="M12 14h.01" />
|
||||
<path d="M16 14h.01" />
|
||||
<path d="M8 18h.01" />
|
||||
<path d="M12 18h.01" />
|
||||
<path d="M16 18h.01" /></symbol><symbol id="check-circle-2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" />
|
||||
<path d="m9 12 2 2 4-4" /></symbol><symbol id="chevron-left" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6" /></symbol><symbol id="chevron-right" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6" /></symbol><symbol id="circle-user-round" viewBox="0 0 24 24"><path d="M17.925 20.056a6 6 0 0 0-11.851.001" />
|
||||
<circle cx="12" cy="11" r="4" />
|
||||
<circle cx="12" cy="12" r="10" /></symbol><symbol id="clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 6v6l4 2" /></symbol><symbol id="folder-kanban" viewBox="0 0 24 24"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
|
||||
<path d="M8 10v4" />
|
||||
<path d="M12 10v2" />
|
||||
<path d="M16 10v6" /></symbol><symbol id="folder-open" viewBox="0 0 24 24"><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" /></symbol><symbol id="inbox" viewBox="0 0 24 24"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
|
||||
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /></symbol><symbol id="layout-dashboard" viewBox="0 0 24 24"><rect width="7" height="9" x="3" y="3" rx="1" />
|
||||
<rect width="7" height="5" x="14" y="3" rx="1" />
|
||||
<rect width="7" height="9" x="14" y="12" rx="1" />
|
||||
<rect width="7" height="5" x="3" y="16" rx="1" /></symbol><symbol id="list-check" viewBox="0 0 24 24"><path d="M16 5H3" />
|
||||
<path d="M16 12H3" />
|
||||
<path d="M11 19H3" />
|
||||
<path d="m15 18 2 2 4-4" /></symbol><symbol id="log-out" viewBox="0 0 24 24"><path d="m16 17 5-5-5-5" />
|
||||
<path d="M21 12H9" />
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /></symbol><symbol id="monitor" viewBox="0 0 24 24"><rect width="20" height="14" x="2" y="3" rx="2" />
|
||||
<line x1="8" x2="16" y1="21" y2="21" />
|
||||
<line x1="12" x2="12" y1="17" y2="21" /></symbol><symbol id="moon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" /></symbol><symbol id="panel-left" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M9 3v18" /></symbol><symbol id="settings" viewBox="0 0 24 24"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" />
|
||||
<circle cx="12" cy="12" r="3" /></symbol><symbol id="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.93 4.93 1.41 1.41" />
|
||||
<path d="m17.66 17.66 1.41 1.41" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.34 17.66-1.41 1.41" />
|
||||
<path d="m19.07 4.93-1.41 1.41" /></symbol><symbol id="triangle-alert" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
|
||||
<path d="M12 9v4" />
|
||||
<path d="M12 17h.01" /></symbol><symbol id="users" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<path d="M16 3.128a4 4 0 0 1 0 7.744" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<circle cx="9" cy="7" r="4" /></symbol><symbol id="x" viewBox="0 0 24 24"><path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" /></symbol></svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,12 @@
|
||||
(() => {
|
||||
const systemIsDark = () => window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
let preference = 'system';
|
||||
try {
|
||||
preference = localStorage.getItem('labtimesheet-theme') || 'system';
|
||||
} catch (_) {
|
||||
// Browser privacy settings may disable storage; the system preference remains usable.
|
||||
}
|
||||
const dark = preference === 'dark' || (preference === 'system' && systemIsDark());
|
||||
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
|
||||
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
|
||||
})();
|
||||
@@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<body>
|
||||
<a th:fragment="buttonLink(href, label, tone)" class="button" th:classappend="${tone == 'primary' ? ' button-primary' : tone == 'danger' ? ' button-danger' : ''}" th:href="${href}" th:text="${label}">Action</a>
|
||||
|
||||
<div th:fragment="input(name, label, value, error)" class="field">
|
||||
<label class="field-label" th:for="${name}" th:text="${label}">Label</label>
|
||||
<input class="control" th:id="${name}" th:name="${name}" th:value="${value}" th:attr="aria-invalid=${error != null},aria-describedby=${error != null ? name + '-error' : null}">
|
||||
<p class="field-error" th:if="${error != null}" th:id="${name + '-error'}" th:text="${error}">Error</p>
|
||||
</div>
|
||||
|
||||
<div th:fragment="select(name, label, options, selected, error)" class="field">
|
||||
<label class="field-label" th:for="${name}" th:text="${label}">Label</label>
|
||||
<select class="control" th:id="${name}" th:name="${name}" th:attr="aria-invalid=${error != null},aria-describedby=${error != null ? name + '-error' : null}">
|
||||
<option th:each="option : ${options}" th:value="${option.key}" th:text="${option.value}" th:selected="${option.key == selected}">Option</option>
|
||||
</select>
|
||||
<p class="field-error" th:if="${error != null}" th:id="${name + '-error'}" th:text="${error}">Error</p>
|
||||
</div>
|
||||
|
||||
<label th:fragment="checkbox(name, label, checked)" class="checkbox"><input type="checkbox" th:id="${name}" th:name="${name}" th:checked="${checked}"><span th:text="${label}">Label</span></label>
|
||||
<section th:fragment="card(title, content)" class="panel"><header class="panel-header"><h2 class="panel-title" th:text="${title}">Title</h2></header><div th:replace="${content}"></div></section>
|
||||
<div th:fragment="metric(label, value, detail)" class="metric"><div class="metric-label" th:text="${label}">Label</div><div class="metric-value" th:text="${value}">Value</div><div class="metric-detail" th:text="${detail}">Detail</div></div>
|
||||
<span th:fragment="badge(label, tone)" class="badge" th:classappend="${' badge-' + tone}" th:attr="aria-label=${'Status: ' + label}" th:text="${label}">Status</span>
|
||||
<nav th:fragment="tabs(items, active)" class="tabs" aria-label="Views"><a th:each="item : ${items}" class="tab" th:href="${item.href}" th:text="${item.label}" th:attr="aria-current=${item.key == active} ? 'page' : null">View</a></nav>
|
||||
<div th:fragment="table(caption, head, body)" class="table-scroll"><table class="data-table"><caption class="sr-only" th:text="${caption}">Data</caption><thead th:replace="${head}"></thead><tbody th:replace="${body}"></tbody></table></div>
|
||||
<nav th:fragment="pagination(previous, next)" class="pagination" aria-label="Pagination"><a class="button" th:if="${previous != null}" th:href="${previous}">Previous</a><a class="button" th:if="${next != null}" th:href="${next}">Next</a></nav>
|
||||
<div th:fragment="alert(tone, message)" class="alert" th:classappend="${' alert-' + tone}" role="alert" th:text="${message}">Message</div>
|
||||
<dialog th:fragment="confirmation(id, title, description)" th:id="${id}" th:attr="aria-labelledby=${id + '-title'},aria-describedby=${id + '-description'}"><h2 th:id="${id + '-title'}" th:text="${title}">Confirm</h2><p th:id="${id + '-description'}" th:text="${description}">Consequence</p><form method="dialog"><button class="button" value="cancel">Cancel</button><button class="button button-danger" value="confirm">Confirm</button></form></dialog>
|
||||
<section th:fragment="empty(title, description)" class="empty-state"><h2 th:text="${title}">No records</h2><p th:text="${description}">Nothing to show.</p></section>
|
||||
<div th:fragment="skeleton(label)" role="status" th:attr="aria-label=${label}"><div class="skeleton"></div></div>
|
||||
<div th:fragment="notificationMenu(items)" class="panel notification-menu"><h2 class="panel-title">Notifications</h2><div th:if="${#lists.isEmpty(items)}">No unread notifications.</div><a th:each="item : ${items}" th:href="${item.actionUrl}" th:text="${item.title}">Notification</a></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,62 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
|
||||
th:fragment="shell(pageTitle, section, activeNav, primaryAction, content)">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title th:text="${pageTitle + ' · Lab Timesheet'}">Lab Timesheet</title>
|
||||
<script th:src="@{/assets/theme.js}"></script>
|
||||
<link rel="stylesheet" th:href="@{/assets/app.css}">
|
||||
<script defer th:src="@{/assets/app.js}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar" aria-label="Primary navigation">
|
||||
<a class="brand" th:href="@{/dashboard}">
|
||||
<span class="brand-mark" aria-hidden="true">LT</span>
|
||||
<span class="sidebar-label"><strong>Lab Timesheet</strong><br><small th:text="${section + ' workspace'}">Workspace</small></span>
|
||||
</a>
|
||||
<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">
|
||||
<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/users}"><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="@{/admin/calendar}"><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}"><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('MENTOR')"><a class="nav-link" th:href="@{/attendance}"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#clock}"></use></svg><span class="sidebar-label">Intern attendance</span></a></li>
|
||||
<li sec:authorize="hasRole('INTERN')"><a class="nav-link" th:href="@{/attendance/me}"><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}"><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('INTERN')"><a class="nav-link" th:href="@{/tasks}"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#list-check}"></use></svg><span class="sidebar-label">Assigned Tasks</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">
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="system">System</option>
|
||||
</select>
|
||||
</div>
|
||||
<a class="account" th:href="@{/profile}"><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">Profile</span></a>
|
||||
<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>
|
||||
</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>
|
||||
<div class="header-title"><span class="breadcrumb" th:text="${section}">Section</span> / <span th:text="${pageTitle}">Page</span></div>
|
||||
<div class="header-actions"><a class="icon-button" th:href="@{/notifications}" aria-label="Notifications" title="Notifications"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#bell}"></use></svg></a></div>
|
||||
</header>
|
||||
<div class="page">
|
||||
<div class="page-heading">
|
||||
<div class="page-heading-copy"><h1 class="page-title" th:text="${pageTitle}">Page</h1></div>
|
||||
<div class="primary-action" th:if="${primaryAction != null}" th:replace="${primaryAction}"></div>
|
||||
</div>
|
||||
<th:block th:replace="${content}"></th:block>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.lab.labtimesheet.ui;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
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 java.nio.charset.StandardCharsets;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
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.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
@WebMvcTest(UiContractWebTest.ContractController.class)
|
||||
@Import(UiContractWebTest.ContractController.class)
|
||||
class UiContractWebTest {
|
||||
|
||||
private final MockMvc mvc;
|
||||
|
||||
@Autowired
|
||||
UiContractWebTest(MockMvc mvc) {
|
||||
this.mvc = mvc;
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "mentor@example.test", roles = "MENTOR")
|
||||
void sharedShellRendersAuthorizedDesktopNavigationBeforeDomainPagesIntegrate() throws Exception {
|
||||
MvcResult result = mvc.perform(get("/ui-contract"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
|
||||
String html = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||
assertTrue(html.contains("Lab Timesheet"));
|
||||
assertTrue(html.contains("Owned Projects"));
|
||||
assertTrue(html.contains("Theme"));
|
||||
assertTrue(html.contains("Logout"));
|
||||
assertFalse(html.contains("Accounts"));
|
||||
assertFalse(html.contains("My attendance"));
|
||||
assertTrue(html.indexOf("/assets/theme.js") < html.indexOf("/assets/app.css"));
|
||||
assertTrue(html.contains("href=\"/assets/icons.svg#panel-left\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void compiledAssetsAreLocalAndContainOnlyTheSelectedIconSprite() throws Exception {
|
||||
ClassPathResource css = new ClassPathResource("static/assets/app.css");
|
||||
ClassPathResource theme = new ClassPathResource("static/assets/theme.js");
|
||||
ClassPathResource script = new ClassPathResource("static/assets/app.js");
|
||||
ClassPathResource sprite = new ClassPathResource("static/assets/icons.svg");
|
||||
|
||||
assertTrue(css.exists());
|
||||
assertTrue(theme.exists());
|
||||
assertTrue(script.exists());
|
||||
assertTrue(sprite.exists());
|
||||
|
||||
String icons = sprite.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertTrue(icons.contains("id=\"panel-left\""));
|
||||
assertTrue(icons.contains("id=\"circle-user-round\""));
|
||||
assertFalse(icons.contains("<script"));
|
||||
assertFalse(icons.contains("href=\"http"));
|
||||
assertFalse(icons.contains("<image"));
|
||||
|
||||
String themeBootstrap = theme.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertTrue(themeBootstrap.contains("localStorage.getItem('labtimesheet-theme')"));
|
||||
assertTrue(themeBootstrap.contains("matchMedia('(prefers-color-scheme: dark)')"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@example.test", roles = "ADMIN")
|
||||
void sharedComponentsExposeAccessibleFormsStatusAndEmptyState() throws Exception {
|
||||
String html = mvc.perform(get("/ui-components"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
|
||||
assertTrue(html.contains("for=\"displayName\""));
|
||||
assertTrue(html.contains("id=\"displayName\""));
|
||||
assertTrue(html.contains("role=\"alert\""));
|
||||
assertTrue(html.contains("No records yet"));
|
||||
assertTrue(html.contains("Status: Active"));
|
||||
assertTrue(html.contains("aria-describedby=\"confirm-dialog-description\""));
|
||||
}
|
||||
|
||||
@Controller
|
||||
public static class ContractController {
|
||||
|
||||
@GetMapping("/ui-contract")
|
||||
String contract() {
|
||||
return "test/layout-consumer";
|
||||
}
|
||||
|
||||
@GetMapping("/ui-components")
|
||||
String components() {
|
||||
return "test/components-consumer";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<body>
|
||||
<div th:replace="~{fragments/components :: input('displayName', 'Display name', 'Mai', null)}"></div>
|
||||
<div th:replace="~{fragments/components :: alert('error', 'Please correct the highlighted fields.')} "></div>
|
||||
<div th:replace="~{fragments/components :: empty('No records yet', 'Create the first record to begin.')} "></div>
|
||||
<span th:replace="~{fragments/components :: badge('Active', 'success')}"></span>
|
||||
<dialog th:replace="~{fragments/components :: confirmation('confirm-dialog', 'Confirm change', 'This action changes the current state.')} "></dialog>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{fragments/layout :: shell(
|
||||
pageTitle='Shared shell contract',
|
||||
section='Mentor',
|
||||
activeNav='dashboard',
|
||||
primaryAction=~{::#primary-action},
|
||||
content=~{::main})}">
|
||||
<body>
|
||||
<a id="primary-action" href="/projects/new">Create Project</a>
|
||||
<main>
|
||||
<p>Domain-owned content</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user