Apply web_template gap analysis improvements (P1-P3)

P1-A: Fix CSP - add fonts.googleapis.com to style-src, fonts.gstatic.com to font-src
P1-B: CSRF token rotation - add rotateToken() to CsrfMiddleware; bootstrap.php rotates
      after successful validation and stores in $GLOBALS['_new_csrf_token']; add
      apiRespond() helper to append token to responses; lt.api interceptor in
      layout_footer.php auto-updates window.CSRF_TOKEN from responses
P1-C: Styled 403/404 error views with TDS layout instead of raw text; index.php now
      uses requireAdmin() helper eliminating 7 duplicated guard blocks (P3-D)
P2-A: Remove duplicate JS-generated keyboard help modal from keyboard-shortcuts.js;
      '?' key now routes to static #lt-keys-help modal in footer
P2-B: Asset versioning driven by config ASSET_VERSION key; base.css and base.js get
      ?v= cache-busting in layout_header.php
P2-C: Add data-theme="dark" to <html> tag to prevent FOUC on light-mode users
P2-E: Escape status value in dashboard.js hover preview class attribute via lt.escHtml()
P2-F: Replace bespoke showLoadingOverlay() with lt-spinner / lt-loading-text from
      base.css; add .lt-loading-overlay wrapper CSS to dashboard.css
P2-G: Add keyboard-shortcuts.js to all 7 admin views so J/K nav and ? help work
P3-A: APP_NAME, APP_SUBTITLE, APP_VERSION driven from config.php; layout header/footer
      use config values instead of hardcoded strings
P3-G: Replace custom initTableSorting() with lt.sortTable.init() which manages aria-sort

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 17:02:40 -04:00
parent f0abadfc57
commit 2e450dc01d
19 changed files with 174 additions and 145 deletions
+14
View File
@@ -38,6 +38,8 @@ if (in_array($_SERVER['REQUEST_METHOD'], ['POST', 'PUT', 'DELETE'])) {
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
// Rotate token after successful validation; endpoints include it in their JSON response
$GLOBALS['_new_csrf_token'] = CsrfMiddleware::rotateToken();
}
header('Content-Type: application/json');
@@ -47,3 +49,15 @@ $currentUser = $_SESSION['user'];
$userId = $currentUser['user_id'];
$isAdmin = $currentUser['is_admin'] ?? false;
$conn = Database::getConnection();
/**
* Output a JSON response, appending the rotated CSRF token so the
* client-side lt.api interceptor can update window.CSRF_TOKEN.
*/
function apiRespond(array $data): void {
if (!empty($GLOBALS['_new_csrf_token'])) {
$data['csrf_token'] = $GLOBALS['_new_csrf_token'];
}
echo json_encode($data);
exit;
}
+21
View File
@@ -323,3 +323,24 @@ kbd {
@media (max-width: 480px) {
.lt-stats-grid { grid-template-columns: 1fr; }
}
/* Loading overlay — wraps lt-spinner for element-level loading states */
.has-lt-overlay { position: relative; }
.lt-loading-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: rgba(var(--bg-primary-rgb, 10,10,10), 0.75);
z-index: 10;
border-radius: inherit;
}
.lt-loading-text {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
}
+24 -23
View File
@@ -288,13 +288,11 @@ function clearAllFilters() {
}
function initTableSorting() {
const tableHeaders = document.querySelectorAll('th');
tableHeaders.forEach((header, index) => {
header.addEventListener('click', () => {
const table = header.closest('table');
sortTable(table, index);
});
});
// Use the TDS lt.sortTable helper which manages aria-sort attributes correctly.
// Falls back to no-op if the table isn't present on this page.
if (window.lt && lt.sortTable && document.getElementById('tickets-table')) {
lt.sortTable.init('tickets-table');
}
}
function initSidebarFilters() {
@@ -1230,7 +1228,7 @@ function showTicketPreview(event) {
currentPreview.innerHTML = `
<div class="preview-header">
<span class="preview-id">#${lt.escHtml(ticketId)}</span>
<span class="preview-status status-${status.replace(/\s+/g, '-')}">${lt.escHtml(status)}</span>
<span class="preview-status status-${lt.escHtml(status.replace(/\s+/g, '-'))}">${lt.escHtml(status)}</span>
</div>
<div class="preview-title">${lt.escHtml(title)}</div>
<div class="preview-meta">
@@ -1330,34 +1328,37 @@ function exportSelectedTickets(format) {
/**
* Show loading overlay on element
* Show TDS spinner overlay on an element.
* Uses lt-spinner + lt-loading-text from base.css.
*/
function showLoadingOverlay(element, message = 'Loading...') {
// Remove existing overlay
const existing = element.querySelector('.loading-overlay');
const existing = element.querySelector('.lt-loading-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.className = 'loading-overlay';
overlay.innerHTML = `
<div class="loading-spinner"></div>
<div class="loading-text">${message}</div>
`;
element.classList.add('has-overlay');
overlay.className = 'lt-loading-overlay';
const spinner = document.createElement('div');
spinner.className = 'lt-spinner';
const text = document.createElement('div');
text.className = 'lt-loading-text';
text.textContent = message;
overlay.appendChild(spinner);
overlay.appendChild(text);
element.classList.add('has-lt-overlay');
element.appendChild(overlay);
}
/**
* Hide loading overlay
* Hide TDS spinner overlay
*/
function hideLoadingOverlay(element) {
const overlay = element.querySelector('.loading-overlay');
const overlay = element.querySelector('.lt-loading-overlay');
if (overlay) {
overlay.classList.add('loading-overlay--hiding');
setTimeout(() => {
overlay.remove();
element.classList.remove('has-overlay');
}, 300);
element.classList.remove('has-lt-overlay');
}
}
+2 -56
View File
@@ -26,60 +26,6 @@ function navigateTableRow(direction) {
}
}
function showKeyboardHelp() {
if (document.getElementById('keyboardHelpModal')) return;
const modal = document.createElement('div');
modal.id = 'keyboardHelpModal';
modal.className = 'lt-modal-overlay';
modal.setAttribute('aria-hidden', 'true');
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
modal.setAttribute('aria-labelledby', 'keyboardHelpModalTitle');
modal.innerHTML = `
<div class="lt-modal lt-modal-sm">
<div class="lt-modal-header">
<span class="lt-modal-title" id="keyboardHelpModalTitle">KEYBOARD SHORTCUTS</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body">
<h4 class="kb-section-heading">Navigation</h4>
<table class="kb-shortcuts-table">
<tr><td><kbd>J</kbd></td><td>Next ticket in list</td></tr>
<tr><td><kbd>K</kbd></td><td>Previous ticket in list</td></tr>
<tr><td><kbd>Enter</kbd></td><td>Open selected ticket</td></tr>
<tr><td><kbd>G</kbd> then <kbd>D</kbd></td><td>Go to Dashboard</td></tr>
</table>
<h4 class="kb-section-heading">Actions</h4>
<table class="kb-shortcuts-table">
<tr><td><kbd>N</kbd></td><td>New ticket</td></tr>
<tr><td><kbd>C</kbd></td><td>Focus comment box</td></tr>
<tr><td><kbd>Ctrl/Cmd+E</kbd></td><td>Toggle Edit Mode</td></tr>
<tr><td><kbd>Ctrl/Cmd+S</kbd></td><td>Save Changes</td></tr>
</table>
<h4 class="kb-section-heading">Quick Status (Ticket Page)</h4>
<table class="kb-shortcuts-table">
<tr><td><kbd>1</kbd></td><td>Set Open</td></tr>
<tr><td><kbd>2</kbd></td><td>Set Pending</td></tr>
<tr><td><kbd>3</kbd></td><td>Set In Progress</td></tr>
<tr><td><kbd>4</kbd></td><td>Set Closed</td></tr>
</table>
<h4 class="kb-section-heading">Other</h4>
<table class="kb-shortcuts-table no-margin">
<tr><td><kbd>Ctrl/Cmd+K</kbd></td><td>Focus Search</td></tr>
<tr><td><kbd>ESC</kbd></td><td>Close Modal / Cancel</td></tr>
<tr><td><kbd>?</kbd></td><td>Show This Help</td></tr>
</table>
</div>
<div class="lt-modal-footer">
<button class="lt-btn lt-btn-ghost" data-modal-close>CLOSE</button>
</div>
</div>
`;
document.body.appendChild(modal);
lt.modal.open('keyboardHelpModal');
}
document.addEventListener('DOMContentLoaded', function() {
if (!window.lt) return;
@@ -101,9 +47,9 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
// ?: Show keyboard shortcuts help (lt.keys.initDefaults also handles this, but we override to show our modal)
// ?: Show keyboard shortcuts help — use the static #lt-keys-help modal in the footer
lt.keys.on('?', function() {
showKeyboardHelp();
if (window.lt) lt.modal.open('lt-keys-help');
});
// J: Next row
+8
View File
@@ -20,6 +20,14 @@ if ($envVars) {
// Global configuration
$GLOBALS['config'] = [
// Application identity
'APP_NAME' => $envVars['APP_NAME'] ?? 'TINKER TICKETS',
'APP_SUBTITLE' => $envVars['APP_SUBTITLE'] ?? 'LotusGuild Infrastructure',
'APP_VERSION' => $envVars['APP_VERSION'] ?? '1.2',
// Asset cache-busting version string (bump when CSS/JS changes)
'ASSET_VERSION' => $envVars['ASSET_VERSION'] ?? '20260329',
// Database settings
'DB_HOST' => $envVars['DB_HOST'] ?? 'localhost',
'DB_USER' => $envVars['DB_USER'] ?? 'root',
+18 -38
View File
@@ -53,6 +53,15 @@ if (!str_starts_with($requestPath, '/api/')) {
}
}
// Helper: require admin or render styled 403 and exit
function requireAdmin(?array $user): void {
if (!$user || empty($user['is_admin'])) {
http_response_code(403);
include __DIR__ . '/views/error_403.php';
exit;
}
}
// Simple router
switch (true) {
case $requestPath == '/' || $requestPath == '':
@@ -176,11 +185,7 @@ switch (true) {
// Admin Routes - require admin privileges
case $requestPath == '/admin/recurring-tickets':
if (!$currentUser || !$currentUser['is_admin']) {
header("HTTP/1.0 403 Forbidden");
echo 'Admin access required';
break;
}
requireAdmin($currentUser);
require_once 'models/RecurringTicketModel.php';
$recurringModel = new RecurringTicketModel($conn);
$recurringTickets = $recurringModel->getAll(true);
@@ -188,11 +193,7 @@ switch (true) {
break;
case $requestPath == '/admin/custom-fields':
if (!$currentUser || !$currentUser['is_admin']) {
header("HTTP/1.0 403 Forbidden");
echo 'Admin access required';
break;
}
requireAdmin($currentUser);
require_once 'models/CustomFieldModel.php';
$fieldModel = new CustomFieldModel($conn);
$customFields = $fieldModel->getAllDefinitions(null, false);
@@ -200,11 +201,7 @@ switch (true) {
break;
case $requestPath == '/admin/workflow':
if (!$currentUser || !$currentUser['is_admin']) {
header("HTTP/1.0 403 Forbidden");
echo 'Admin access required';
break;
}
requireAdmin($currentUser);
$result = $conn->query("SELECT * FROM status_transitions ORDER BY from_status, to_status");
$workflows = [];
while ($row = $result->fetch_assoc()) {
@@ -214,11 +211,7 @@ switch (true) {
break;
case $requestPath == '/admin/templates':
if (!$currentUser || !$currentUser['is_admin']) {
header("HTTP/1.0 403 Forbidden");
echo 'Admin access required';
break;
}
requireAdmin($currentUser);
$result = $conn->query("SELECT * FROM ticket_templates ORDER BY template_name");
$templates = [];
while ($row = $result->fetch_assoc()) {
@@ -228,11 +221,7 @@ switch (true) {
break;
case $requestPath == '/admin/audit-log':
if (!$currentUser || !$currentUser['is_admin']) {
header("HTTP/1.0 403 Forbidden");
echo 'Admin access required';
break;
}
requireAdmin($currentUser);
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
$perPage = 50;
$offset = ($page - 1) * $perPage;
@@ -314,11 +303,7 @@ switch (true) {
break;
case $requestPath == '/admin/api-keys':
if (!$currentUser || !$currentUser['is_admin']) {
header("HTTP/1.0 403 Forbidden");
echo 'Admin access required';
break;
}
requireAdmin($currentUser);
require_once 'models/ApiKeyModel.php';
$apiKeyModel = new ApiKeyModel($conn);
$apiKeys = $apiKeyModel->getAllKeys();
@@ -326,11 +311,7 @@ switch (true) {
break;
case $requestPath == '/admin/user-activity':
if (!$currentUser || !$currentUser['is_admin']) {
header("HTTP/1.0 403 Forbidden");
echo 'Admin access required';
break;
}
requireAdmin($currentUser);
$dateRange = [
'from' => $_GET['date_from'] ?? date('Y-m-d', strtotime('-30 days')),
@@ -407,9 +388,8 @@ switch (true) {
exit;
default:
// 404 Not Found
header("HTTP/1.0 404 Not Found");
echo '404 Page Not Found';
http_response_code(404);
include __DIR__ . '/views/error_404.php';
break;
}
+12
View File
@@ -44,6 +44,18 @@ class CsrfMiddleware {
return hash_equals($_SESSION[self::$tokenName], $token);
}
/**
* Rotate the CSRF token after a successful validated POST.
* Call this after validateToken() returns true, then include
* the new token in the JSON response as 'csrf_token' so the
* client can update window.CSRF_TOKEN for subsequent requests.
*
* @return string The new token
*/
public static function rotateToken(): string {
return self::generateToken();
}
/**
* Check if token is expired
*/
+1 -1
View File
@@ -28,7 +28,7 @@ class SecurityHeadersMiddleware {
// Content Security Policy - restricts where resources can be loaded from
// Using nonces for scripts to prevent XSS attacks while allowing inline scripts with valid nonces
// All inline event handlers have been refactored to use addEventListener with data-action attributes
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self';");
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';");
// Prevent clickjacking by disallowing framing
header("X-Frame-Options: DENY");
+1 -1
View File
@@ -5,7 +5,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'API Keys';
$activeNav = 'admin-api-keys';
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
$pageScripts = [];
$pageScripts = ['/assets/js/keyboard-shortcuts.js'];
include __DIR__ . '/../../views/layout_header.php';
?>
+1 -1
View File
@@ -5,7 +5,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Audit Log';
$activeNav = 'admin-audit-log';
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
$pageScripts = [];
$pageScripts = ['/assets/js/keyboard-shortcuts.js'];
include __DIR__ . '/../../views/layout_header.php';
?>
+1 -1
View File
@@ -5,7 +5,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Custom Fields';
$activeNav = 'admin-custom-fields';
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
$pageScripts = [];
$pageScripts = ['/assets/js/keyboard-shortcuts.js'];
include __DIR__ . '/../../views/layout_header.php';
?>
+1 -1
View File
@@ -5,7 +5,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Recurring Tickets';
$activeNav = 'admin-recurring';
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
$pageScripts = [];
$pageScripts = ['/assets/js/keyboard-shortcuts.js'];
include __DIR__ . '/../../views/layout_header.php';
?>
+1 -1
View File
@@ -5,7 +5,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Templates';
$activeNav = 'admin-templates';
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
$pageScripts = [];
$pageScripts = ['/assets/js/keyboard-shortcuts.js'];
include __DIR__ . '/../../views/layout_header.php';
?>
+1 -1
View File
@@ -5,7 +5,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'User Activity';
$activeNav = 'admin-user-activity';
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
$pageScripts = [];
$pageScripts = ['/assets/js/keyboard-shortcuts.js'];
include __DIR__ . '/../../views/layout_header.php';
?>
+1 -1
View File
@@ -5,7 +5,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Workflow Designer';
$activeNav = 'admin-workflow';
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
$pageScripts = [];
$pageScripts = ['/assets/js/keyboard-shortcuts.js'];
include __DIR__ . '/../../views/layout_header.php';
?>
+18
View File
@@ -0,0 +1,18 @@
<?php
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = '403 Forbidden';
$activeNav = '';
$pageStyles = [];
include __DIR__ . '/../views/layout_header.php';
?>
<div class="lt-frame" style="max-width:32rem;margin:4rem auto">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<div class="lt-section-header lt-text-danger">[ 403 ] ACCESS DENIED</div>
<div class="lt-section-body" style="text-align:center">
<p class="lt-text-muted lt-mb-md">You do not have permission to access this resource.</p>
<a href="/" class="lt-btn lt-btn-primary">&larr; Dashboard</a>
</div>
</div>
<?php include __DIR__ . '/../views/layout_footer.php'; ?>
+18
View File
@@ -0,0 +1,18 @@
<?php
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = '404 Not Found';
$activeNav = '';
$pageStyles = [];
include __DIR__ . '/../views/layout_header.php';
?>
<div class="lt-frame" style="max-width:32rem;margin:4rem auto">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<div class="lt-section-header lt-text-amber">[ 404 ] NOT FOUND</div>
<div class="lt-section-body" style="text-align:center">
<p class="lt-text-muted lt-mb-md">The page you requested does not exist.</p>
<a href="/" class="lt-btn lt-btn-primary">&larr; Dashboard</a>
</div>
</div>
<?php include __DIR__ . '/../views/layout_footer.php'; ?>
+17 -9
View File
@@ -52,7 +52,7 @@
<span class="lt-footer-sep">|</span>
<button type="button" class="lt-footer-hint" data-action="show-keyboard-help" title="Show keyboard shortcuts (?)"><span class="lt-footer-key">[ ? ]</span> HELP</button>
</nav>
<span aria-label="Application version">TINKER TICKETS &mdash; TDS v1.2</span>
<span aria-label="Application version"><?= htmlspecialchars($GLOBALS['config']['APP_NAME'] ?? 'TINKER TICKETS', ENT_QUOTES, 'UTF-8') ?> &mdash; TDS v<?= htmlspecialchars($GLOBALS['config']['APP_VERSION'] ?? '1.2', ENT_QUOTES, 'UTF-8') ?></span>
</footer>
<!-- ================================================================
@@ -131,7 +131,7 @@
<!-- LT INIT boot animation + global UI init (base.js handles keys/nav automatically) -->
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
if (window.lt) {
lt.init({ bootName: 'TINKER TICKETS' });
lt.init({ bootName: <?= json_encode($GLOBALS['config']['APP_NAME'] ?? 'TINKER TICKETS', JSON_HEX_TAG) ?> });
// Theme toggle button
var themeBtn = document.getElementById('lt-theme-btn');
@@ -155,19 +155,27 @@
]);
}
// Patch lt.api mutating methods to auto-rotate CSRF token when server returns a new one
if (window.lt && lt.api) {
['post', 'put', 'patch', 'delete'].forEach(function(method) {
if (typeof lt.api[method] !== 'function') return;
var _orig = lt.api[method];
lt.api[method] = function(url, body) {
return _orig.call(lt.api, url, body).then(function(data) {
if (data && data.csrf_token) window.CSRF_TOKEN = data.csrf_token;
return data;
});
};
});
}
// Footer hint bar actions (keyboard help + settings — work on all pages)
document.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action]');
if (!btn) return;
var action = btn.getAttribute('data-action');
if (action === 'show-keyboard-help') {
if (typeof showKeyboardHelp === 'function') {
showKeyboardHelp();
} else if (window.lt) {
var h = document.getElementById('lt-keys-help');
if (h) lt.modal.open('lt-keys-help');
else lt.toast.info('Keyboard shortcuts: ESC=close Ctrl+K=search ?=this help');
}
if (window.lt) lt.modal.open('lt-keys-help');
} else if (action === 'open-settings' || action === 'open-settings-modal') {
if (typeof openSettingsModal === 'function') {
openSettingsModal();
+13 -10
View File
@@ -17,19 +17,22 @@
$_lt_user = $GLOBALS['currentUser'] ?? [];
$_lt_isAdmin = !empty($_lt_user['is_admin']);
$_lt_navActive = $activeNav ?? 'dashboard';
$_lt_appName = $GLOBALS['config']['APP_NAME'] ?? 'TINKER TICKETS';
$_lt_subtitle = $GLOBALS['config']['APP_SUBTITLE'] ?? 'LotusGuild Infrastructure';
$_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
?>
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#030508">
<title><?= htmlspecialchars($pageTitle ?? 'Dashboard', ENT_QUOTES, 'UTF-8') ?> &mdash; Tinker Tickets</title>
<title><?= htmlspecialchars($pageTitle ?? 'Dashboard', ENT_QUOTES, 'UTF-8') ?> &mdash; <?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?></title>
<meta name="robots" content="noindex, nofollow">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="/assets/css/base.css?v=<?= $_lt_assetVer ?>">
<?php if (!empty($pageStyles)): ?>
<?php foreach ($pageStyles as $_lt_css): ?>
<link rel="stylesheet" href="<?= htmlspecialchars($_lt_css, ENT_QUOTES, 'UTF-8') ?>">
@@ -37,8 +40,8 @@ $_lt_navActive = $activeNav ?? 'dashboard';
<?php endif; ?>
<link rel="icon" href="/assets/images/favicon.png" type="image/png">
<!-- Base JS loaded in head so lt.* is available for inline view scripts -->
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>" src="/assets/js/base.js"></script>
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>" src="/assets/js/utils.js"></script>
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>" src="/assets/js/base.js?v=<?= $_lt_assetVer ?>"></script>
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>" src="/assets/js/utils.js?v=<?= $_lt_assetVer ?>"></script>
<!-- Inline JS globals (CSRF, timezone, user) available immediately -->
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
window.CSRF_TOKEN = <?= json_encode(CsrfMiddleware::getToken(), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) ?>;
@@ -58,14 +61,14 @@ $_lt_navActive = $activeNav ?? 'dashboard';
<a class="lt-skip-link" href="#main-content">Skip to main content</a>
<!-- BOOT OVERLAY controlled by lt.boot() in base.js; shown once per session -->
<div id="lt-boot" class="lt-boot-overlay" data-app-name="TINKER TICKETS" style="display:none" aria-hidden="true">
<div id="lt-boot" class="lt-boot-overlay" data-app-name="<?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?>" style="display:none" aria-hidden="true">
<pre id="lt-boot-text" class="lt-boot-text"></pre>
</div>
<!-- MOBILE NAV DRAWER matches web_template structure exactly -->
<div id="lt-nav-drawer" class="lt-nav-drawer" aria-hidden="true" role="dialog" aria-modal="true" aria-label="Navigation menu">
<div class="lt-nav-drawer-header">
<span class="lt-brand-title">TINKER TICKETS</span>
<span class="lt-brand-title"><?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?></span>
<button type="button" class="lt-nav-drawer-close" id="lt-nav-drawer-close" aria-label="Close navigation">&#x2715;</button>
</div>
<nav class="lt-nav-drawer-links" aria-label="Mobile navigation">
@@ -108,10 +111,10 @@ $_lt_navActive = $activeNav ?? 'dashboard';
<div class="lt-brand">
<a href="/"
class="lt-brand-title lt-glitch"
data-text="TINKER TICKETS"
data-text="<?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?>"
style="text-decoration:none"
aria-label="Tinker Tickets home">TINKER TICKETS</a>
<span class="lt-brand-subtitle">LotusGuild Infrastructure</span>
aria-label="<?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?> home"><?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?></a>
<span class="lt-brand-subtitle"><?= htmlspecialchars($_lt_subtitle, ENT_QUOTES, 'UTF-8') ?></span>
</div>
<!-- Desktop navigation -->