diff --git a/changelog.md b/changelog.md
index 3f0f5e3..189f9ad 100644
--- a/changelog.md
+++ b/changelog.md
@@ -8,3 +8,4 @@
- Added SQLite-backed pytest coverage for repository and unit-of-work behaviours to validate persistence interactions.
- Exposed project and scenario CRUD APIs with validated schemas and integrated them into the FastAPI application.
- Connected project and scenario routers to new Jinja2 list/detail/edit views with HTML forms and redirects.
+- Implemented FR-009 client-side enhancements with responsive navigation toggle, mobile-first scenario tables, and shared asset loading across templates.
diff --git a/static/css/main.css b/static/css/main.css
index 3b22daa..7848704 100644
--- a/static/css/main.css
+++ b/static/css/main.css
@@ -52,8 +52,8 @@ body {
body {
margin: 0;
- font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', 'Roboto',
- Helvetica, Arial, 'Apple Color Emoji', 'Segoe UI Emoji';
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", "Roboto",
+ Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
color: var(--text);
background: linear-gradient(180deg, var(--bg) 0%, var(--bg-2) 100%);
line-height: 1.45;
@@ -337,7 +337,7 @@ a {
gap: var(--space-sm);
font-weight: 600;
color: var(--text);
- font-family: 'Fira Code', 'Consolas', 'Courier New', monospace;
+ font-family: "Fira Code", "Consolas", "Courier New", monospace;
font-size: 0.85rem;
}
@@ -366,7 +366,7 @@ a {
}
.color-value-input {
- font-family: 'Fira Code', 'Consolas', 'Courier New', monospace;
+ font-family: "Fira Code", "Consolas", "Courier New", monospace;
}
.color-value-input[disabled] {
@@ -395,7 +395,7 @@ a {
}
.env-overrides-table code {
- font-family: 'Fira Code', 'Consolas', 'Courier New', monospace;
+ font-family: "Fira Code", "Consolas", "Courier New", monospace;
font-size: 0.85rem;
}
@@ -550,7 +550,7 @@ a {
}
.btn.is-loading::after {
- content: '';
+ content: "";
width: 0.85rem;
height: 0.85rem;
border: 2px solid rgba(255, 255, 255, 0.6);
@@ -656,14 +656,14 @@ a {
color: var(--color-surface-alt);
padding: 1rem;
border-radius: 8px;
- font-family: 'Fira Code', 'Consolas', 'Courier New', monospace;
+ font-family: "Fira Code", "Consolas", "Courier New", monospace;
overflow-x: auto;
margin-top: 1.5rem;
}
.monospace-input {
width: 100%;
- font-family: 'Fira Code', 'Consolas', 'Courier New', monospace;
+ font-family: "Fira Code", "Consolas", "Courier New", monospace;
min-height: 120px;
}
@@ -740,6 +740,72 @@ tbody tr:nth-child(even) {
font-size: 0.9rem;
}
+.sidebar-toggle {
+ display: none;
+ align-items: center;
+ gap: 0.6rem;
+ padding: 0.55rem 1rem;
+ border-radius: 999px;
+ border: none;
+ background: linear-gradient(135deg, var(--brand-2), var(--brand));
+ color: var(--color-text-dark);
+ font-weight: 600;
+ cursor: pointer;
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25);
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+}
+
+.sidebar-toggle:hover,
+.sidebar-toggle:focus-visible {
+ transform: translateY(-1px);
+ box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
+}
+
+.sidebar-toggle:focus-visible {
+ outline: 2px solid rgba(255, 255, 255, 0.65);
+ outline-offset: 3px;
+}
+
+.sidebar-toggle-icon {
+ position: relative;
+ display: inline-block;
+ width: 18px;
+ height: 2px;
+ background-color: currentColor;
+}
+
+.sidebar-toggle-icon::before,
+.sidebar-toggle-icon::after {
+ content: "";
+ position: absolute;
+ left: 0;
+ width: 18px;
+ height: 2px;
+ background-color: currentColor;
+}
+
+.sidebar-toggle-icon::before {
+ top: -6px;
+}
+
+.sidebar-toggle-icon::after {
+ top: 6px;
+}
+
+.sidebar-toggle-label {
+ font-size: 0.95rem;
+}
+
+.sidebar-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(7, 11, 17, 0.6);
+ z-index: 800;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.25s ease;
+}
+
@media (max-width: 1024px) {
.app-sidebar {
width: 240px;
@@ -790,4 +856,39 @@ tbody tr:nth-child(even) {
.dashboard-columns {
grid-template-columns: 1fr;
}
+
+ .sidebar-toggle {
+ display: inline-flex;
+ margin: 1rem auto 1.5rem;
+ }
+
+ body.sidebar-collapsed .app-sidebar {
+ display: none;
+ }
+
+ body.sidebar-open {
+ overflow: hidden;
+ }
+
+ body.sidebar-open .app-sidebar {
+ display: block;
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: min(320px, 82vw);
+ height: 100vh;
+ overflow-y: auto;
+ z-index: 900;
+ box-shadow: 0 12px 30px rgba(8, 14, 25, 0.4);
+ }
+
+ body.sidebar-open .sidebar-overlay {
+ opacity: 1;
+ pointer-events: auto;
+ }
+
+ body.sidebar-open .app-main {
+ position: relative;
+ z-index: 950;
+ }
}
diff --git a/static/css/scenarios.css b/static/css/scenarios.css
index 27efe04..981f7a3 100644
--- a/static/css/scenarios.css
+++ b/static/css/scenarios.css
@@ -76,3 +76,86 @@
transform: translateY(-1px);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.25);
}
+
+.scenario-filters {
+ display: grid;
+ gap: 0.75rem;
+ margin-bottom: 1.5rem;
+}
+
+.scenario-filters .filter-field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.scenario-filters .filter-actions {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ align-items: center;
+}
+
+.scenario-filters input,
+.scenario-filters select {
+ width: 100%;
+ padding: 0.6rem 0.75rem;
+ border-radius: var(--radius-sm);
+ border: 1px solid var(--color-border);
+ background: rgba(8, 12, 19, 0.75);
+ color: var(--text);
+}
+
+.table-responsive {
+ width: 100%;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ border-radius: var(--table-radius);
+ margin: 0;
+}
+
+.table-responsive .table {
+ min-width: 640px;
+}
+
+.table-responsive::-webkit-scrollbar {
+ height: 6px;
+}
+
+.table-responsive::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.2);
+ border-radius: 999px;
+}
+
+@media (min-width: 720px) {
+ .scenario-filters {
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ align-items: end;
+ }
+
+ .scenario-filters .filter-actions {
+ justify-content: flex-end;
+ }
+
+ .table-responsive .table {
+ min-width: 100%;
+ }
+}
+
+@media (max-width: 640px) {
+ .breadcrumb {
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ }
+
+ .table th,
+ .table td {
+ padding: 0.55rem 0.65rem;
+ font-size: 0.9rem;
+ white-space: nowrap;
+ }
+
+ .table tbody tr {
+ border-radius: var(--radius-sm);
+ }
+}
diff --git a/static/js/projects.js b/static/js/projects.js
index ebad17a..c2fbbbe 100644
--- a/static/js/projects.js
+++ b/static/js/projects.js
@@ -1,13 +1,9 @@
document.addEventListener("DOMContentLoaded", () => {
const table = document.querySelector("[data-project-table]");
- if (!table) {
- return;
- }
-
- const rows = Array.from(table.querySelectorAll("tbody tr"));
+ const rows = table ? Array.from(table.querySelectorAll("tbody tr")) : [];
const filterInput = document.querySelector("[data-project-filter]");
- if (filterInput) {
+ if (table && filterInput) {
filterInput.addEventListener("input", () => {
const query = filterInput.value.trim().toLowerCase();
rows.forEach((row) => {
@@ -16,4 +12,105 @@ document.addEventListener("DOMContentLoaded", () => {
});
});
}
+
+ const sidebar = document.querySelector(".app-sidebar");
+ const appMain = document.querySelector(".app-main");
+ if (!sidebar || !appMain) {
+ return;
+ }
+
+ const body = document.body;
+ const mobileQuery = window.matchMedia("(max-width: 900px)");
+ let toggleButton = document.querySelector("[data-sidebar-toggle]");
+
+ if (!toggleButton) {
+ toggleButton = document.createElement("button");
+ toggleButton.type = "button";
+ toggleButton.className = "sidebar-toggle";
+ toggleButton.setAttribute("data-sidebar-toggle", "");
+ toggleButton.setAttribute("aria-expanded", "false");
+ toggleButton.setAttribute("aria-label", "Toggle primary navigation");
+ toggleButton.hidden = true;
+ toggleButton.innerHTML = [
+ '',
+ '',
+ ].join("");
+ appMain.insertBefore(toggleButton, appMain.firstChild);
+ }
+
+ let overlay = document.querySelector("[data-sidebar-overlay]");
+ if (!overlay) {
+ overlay = document.createElement("div");
+ overlay.className = "sidebar-overlay";
+ overlay.setAttribute("data-sidebar-overlay", "");
+ overlay.setAttribute("aria-hidden", "true");
+ document.body.appendChild(overlay);
+ }
+
+ const primaryNav = document.querySelector(".sidebar-nav");
+ if (primaryNav) {
+ if (!primaryNav.id) {
+ primaryNav.id = "primary-navigation";
+ }
+ toggleButton.setAttribute("aria-controls", primaryNav.id);
+ }
+
+ const openSidebar = () => {
+ body.classList.remove("sidebar-collapsed");
+ body.classList.add("sidebar-open");
+ toggleButton.setAttribute("aria-expanded", "true");
+ overlay.setAttribute("aria-hidden", "false");
+ };
+
+ const closeSidebar = (focusToggle = false) => {
+ body.classList.add("sidebar-collapsed");
+ body.classList.remove("sidebar-open");
+ toggleButton.setAttribute("aria-expanded", "false");
+ overlay.setAttribute("aria-hidden", "true");
+ if (focusToggle) {
+ toggleButton.focus({ preventScroll: true });
+ }
+ };
+
+ const toggleSidebar = () => {
+ if (body.classList.contains("sidebar-open")) {
+ closeSidebar();
+ } else {
+ openSidebar();
+ sidebar.setAttribute("aria-hidden", "false");
+ }
+ };
+
+ const applyResponsiveState = (mql) => {
+ if (!mql.matches) {
+ toggleButton.hidden = true;
+ body.classList.remove("sidebar-open", "sidebar-collapsed");
+ sidebar.setAttribute("aria-hidden", "true");
+ overlay.setAttribute("aria-hidden", "true");
+ sidebar.removeAttribute("aria-hidden");
+ return;
+ }
+
+ toggleButton.hidden = false;
+ if (!body.classList.contains("sidebar-open")) {
+ body.classList.add("sidebar-collapsed");
+ sidebar.setAttribute("aria-hidden", "true");
+ }
+ };
+
+ toggleButton.addEventListener("click", toggleSidebar);
+ overlay.addEventListener("click", () => closeSidebar());
+
+ document.addEventListener("keydown", (event) => {
+ if (event.key === "Escape" && body.classList.contains("sidebar-open")) {
+ closeSidebar(true);
+ }
+ });
+
+ applyResponsiveState(mobileQuery);
+ if (typeof mobileQuery.addEventListener === "function") {
+ mobileQuery.addEventListener("change", applyResponsiveState);
+ } else if (typeof mobileQuery.addListener === "function") {
+ mobileQuery.addListener(applyResponsiveState);
+ }
});
diff --git a/templates/base.html b/templates/base.html
index 53722db..b159e20 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -20,6 +20,7 @@
{% block scripts %}{% endblock %}
+