diff --git a/assets/css/base.css b/assets/css/base.css
new file mode 100644
index 0000000..c594a9b
--- /dev/null
+++ b/assets/css/base.css
@@ -0,0 +1,1729 @@
+/* ================================================================
+ LOTUSGUILD TERMINAL DESIGN SYSTEM v1.0 — base.css
+ Unified CSS for all LotusGuild web applications
+
+ Apps: Tinker Tickets (PHP), PULSE (Node.js), GANDALF (Flask)
+ Aesthetic: Retro CRT terminal, phosphor green, ASCII art borders
+ Prefix: .lt-* (LotusGuild Terminal)
+
+ TABLE OF CONTENTS
+ 01. CSS Custom Properties
+ 02. Reset & Base
+ 03. CRT & Terminal Effects
+ 04. Typography
+ 05. Layout Primitives
+ 06. Header & Navigation
+ 07. ASCII Frame System
+ 08. Card Component
+ 09. Buttons
+ 10. Forms & Inputs
+ 11. Tables
+ 12. Status Badges, Chips, Priority
+ 13. Modals
+ 14. Toast Notifications
+ 15. Tab Navigation
+ 16. Sidebar / Filter Panel
+ 17. Stats Widgets
+ 18. Inline Messages
+ 19. Loading & Empty States
+ 20. Boot Sequence Overlay
+ 21. Log / Timeline Entries
+ 22. Animations (keyframes)
+ 23. Responsive Design
+ 24. Utility Classes
+ 25. Print Styles
+ 26. Accessibility
+ ================================================================ */
+
+/* ----------------------------------------------------------------
+ 01. CSS CUSTOM PROPERTIES
+ ---------------------------------------------------------------- */
+:root {
+ /* --- Backgrounds --- */
+ --bg-primary: #0a0a0a; /* Main background: near-black */
+ --bg-secondary: #1a1a1a; /* Surface: dark grey */
+ --bg-tertiary: #2a2a2a; /* Raised surface / hover states */
+ --bg-terminal: #001a00; /* Deep green-tinted black for terminal panels */
+
+ /* --- Terminal Green (Primary accent) --- */
+ --terminal-green: #00ff41;
+ --terminal-green-dim: rgba(0, 255, 65, 0.15);
+ --terminal-green-dark: #00cc33;
+ --terminal-green-muted: #008822;
+
+ /* --- Amber (Secondary / highlight / headings) --- */
+ --terminal-amber: #ffb000;
+ --terminal-amber-dim: rgba(255, 176, 0, 0.15);
+
+ /* --- Cyan (Tertiary / uplinks / info) --- */
+ --terminal-cyan: #00ffff;
+ --terminal-cyan-dim: rgba(0, 255, 255, 0.12);
+
+ /* --- Red (Error / critical / closed) --- */
+ --terminal-red: #ff4444;
+ --terminal-red-dim: rgba(255, 68, 68, 0.15);
+
+ /* --- Orange (Warning) --- */
+ --terminal-orange: #ff8c00;
+ --terminal-orange-dim: rgba(255, 140, 0, 0.15);
+
+ /* --- Text --- */
+ --text-primary: #00ff41; /* Body text */
+ --text-secondary: #00cc33; /* Subdued text */
+ --text-muted: #00bb33; /* Muted / placeholder text */
+
+ /* --- Borders --- */
+ --border-color: rgba(0, 255, 65, 0.35); /* Default border (semi-transparent) */
+ --border-color-hi: #00ff41; /* Highlighted / focused border */
+ --border-color-dim: rgba(0, 255, 65, 0.12); /* Subtle row separator */
+
+ /* --- Ticket / workflow status colours --- */
+ --status-open: #28a745;
+ --status-pending: #9c27b0;
+ --status-in-progress: #ffc107;
+ --status-closed: #dc3545;
+ --status-online: #28a745;
+ --status-offline: #dc3545;
+ --status-running: #ffc107;
+ --status-completed: #28a745;
+ --status-failed: #dc3545;
+ --status-waiting: #ffc107;
+
+ /* --- Priority colours (P1 critical → P5 minimal) --- */
+ --priority-1: #ff4d4d; /* Critical */
+ --priority-2: #ffa726; /* High */
+ --priority-3: #42a5f5; /* Medium */
+ --priority-4: #66bb6a; /* Low */
+ --priority-5: #9e9e9e; /* Minimal */
+
+ /* --- Glow — text-shadow stacks --- */
+ --glow-green: 0 0 5px #00ff41, 0 0 10px #00ff41, 0 0 15px #00ff41;
+ --glow-green-intense: 0 0 8px #00ff41, 0 0 16px #00ff41, 0 0 24px #00ff41,
+ 0 0 32px rgba(0, 255, 65, 0.5);
+ --glow-amber: 0 0 5px #ffb000, 0 0 10px #ffb000, 0 0 15px #ffb000;
+ --glow-amber-intense: 0 0 8px #ffb000, 0 0 16px #ffb000, 0 0 24px #ffb000;
+ --glow-cyan: 0 0 5px #00ffff, 0 0 10px rgba(0, 255, 255, 0.4);
+ --glow-red: 0 0 5px #ff4444, 0 0 10px #ff4444;
+
+ /* --- Glow — box-shadow (element halos) --- */
+ --box-glow-green: 0 0 12px rgba(0, 255, 65, 0.3);
+ --box-glow-amber: 0 0 12px rgba(255, 176, 0, 0.3);
+ --box-glow-red: 0 0 12px rgba(255, 68, 68, 0.3);
+ --box-glow-cyan: 0 0 12px rgba(0, 255, 255, 0.25);
+
+ /* --- Typography --- */
+ --font-mono: 'Courier New', 'Consolas', 'Monaco', 'Menlo', monospace;
+
+ /* --- Spacing scale --- */
+ --space-xs: 0.25rem; /* 4px */
+ --space-sm: 0.5rem; /* 8px */
+ --space-md: 1rem; /* 16px */
+ --space-lg: 1.5rem; /* 24px */
+ --space-xl: 2rem; /* 32px */
+ --space-2xl: 3rem; /* 48px */
+
+ /* --- Layout --- */
+ --sidebar-width: 240px;
+ --sidebar-width-sm: 200px;
+ --header-height: 58px;
+ --container-max: 1600px;
+
+ /* --- Transitions --- */
+ --transition-fast: all 0.15s ease;
+ --transition-default: all 0.3s ease;
+
+ /* --- Z-index ladder --- */
+ --z-base: 1;
+ --z-dropdown: 100;
+ --z-sticky: 200;
+ --z-fixed: 300;
+ --z-modal-backdrop: 400;
+ --z-modal: 500;
+ --z-popover: 600;
+ --z-tooltip: 700;
+ --z-toast: 800;
+ --z-overlay: 9999;
+}
+
+/* ----------------------------------------------------------------
+ 02. RESET & BASE
+ ---------------------------------------------------------------- */
+*, *::before, *::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+html {
+ font-size: 16px;
+ scroll-behavior: smooth;
+}
+
+body {
+ background-color: var(--bg-primary);
+ color: var(--text-primary);
+ font-family: var(--font-mono);
+ line-height: 1.6;
+ min-height: 100vh;
+ overflow-x: hidden;
+ /* Subtle screen flicker every 30 s — authentic CRT feel */
+ animation: flicker 0.2s ease-in-out 30s infinite;
+}
+
+a {
+ color: var(--terminal-green);
+ text-decoration: none;
+ transition: var(--transition-fast);
+}
+a:hover {
+ color: var(--terminal-amber);
+ text-shadow: var(--glow-amber);
+}
+
+ul, ol { list-style: none; }
+
+img, svg { display: block; max-width: 100%; }
+
+/* ----------------------------------------------------------------
+ 03. CRT & TERMINAL EFFECTS
+ ---------------------------------------------------------------- */
+
+/* Horizontal scanline overlay — fixed over the entire viewport */
+body::before {
+ content: '';
+ position: fixed;
+ inset: 0;
+ background: repeating-linear-gradient(
+ 0deg,
+ rgba(0, 0, 0, 0.15) 0px,
+ rgba(0, 0, 0, 0.15) 1px,
+ transparent 1px,
+ transparent 2px
+ );
+ pointer-events: none;
+ z-index: var(--z-overlay);
+ animation: scanline 8s linear infinite;
+}
+
+/* Binary data-stream watermark — bottom-right corner */
+body::after {
+ content: '10101010';
+ position: fixed;
+ bottom: 10px;
+ right: 14px;
+ font-family: var(--font-mono);
+ font-size: 0.55rem;
+ color: var(--terminal-green);
+ opacity: 0.07;
+ pointer-events: none;
+ letter-spacing: 2px;
+ animation: data-stream 3s steps(1) infinite;
+}
+
+/* ----------------------------------------------------------------
+ 04. TYPOGRAPHY
+ ---------------------------------------------------------------- */
+h1, h2, h3, h4, h5, h6 {
+ font-family: var(--font-mono);
+ font-weight: bold;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ line-height: 1.3;
+}
+
+h1 {
+ font-size: 1.5rem;
+ color: var(--terminal-amber);
+ text-shadow: var(--glow-amber-intense);
+}
+h1::before { content: '>> '; color: var(--terminal-green); text-shadow: var(--glow-green); }
+
+h2 {
+ font-size: 1.2rem;
+ color: var(--terminal-amber);
+ text-shadow: var(--glow-amber);
+}
+
+h3 {
+ font-size: 1rem;
+ color: var(--terminal-amber);
+ text-shadow: var(--glow-amber);
+}
+h3::before { content: '═══ '; color: var(--terminal-green); }
+h3::after { content: ' ═══'; color: var(--terminal-green); }
+
+h4 {
+ font-size: 0.9rem;
+ color: var(--terminal-amber);
+ letter-spacing: 0.07em;
+}
+
+p {
+ color: var(--text-primary);
+ margin-bottom: var(--space-md);
+}
+
+code {
+ font-family: var(--font-mono);
+ background: var(--bg-terminal);
+ color: var(--terminal-green);
+ padding: 2px 6px;
+ border: 1px solid var(--border-color-dim);
+ font-size: 0.9em;
+}
+
+pre {
+ font-family: var(--font-mono);
+ background: var(--bg-terminal);
+ color: var(--terminal-green);
+ padding: var(--space-md);
+ border: 1px solid var(--border-color);
+ overflow-x: auto;
+ line-height: 1.5;
+}
+
+/* ----------------------------------------------------------------
+ 05. LAYOUT PRIMITIVES
+ ---------------------------------------------------------------- */
+.lt-container {
+ max-width: var(--container-max);
+ margin: 0 auto;
+ padding: 0 var(--space-lg);
+}
+
+.lt-main {
+ padding: var(--space-lg);
+ min-height: calc(100vh - var(--header-height));
+}
+
+/* Sidebar + content split */
+.lt-layout {
+ display: flex;
+ gap: var(--space-md);
+ align-items: flex-start;
+}
+
+.lt-content { flex: 1; min-width: 0; }
+
+/* Page header (below nav, above content) */
+.lt-page-header {
+ margin-bottom: var(--space-lg);
+ padding-bottom: var(--space-md);
+ border-bottom: 1px solid var(--border-color);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-md);
+ flex-wrap: wrap;
+}
+
+.lt-page-title {
+ font-size: 1.05rem;
+ font-weight: bold;
+ color: var(--terminal-amber);
+ text-shadow: var(--glow-amber);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+.lt-page-title::before { content: '>> '; color: var(--terminal-green); }
+
+/* ----------------------------------------------------------------
+ 06. HEADER & NAVIGATION
+ ---------------------------------------------------------------- */
+.lt-header {
+ height: var(--header-height);
+ background: var(--bg-secondary);
+ border-bottom: 2px solid var(--terminal-green);
+ box-shadow: 0 2px 16px rgba(0, 255, 65, 0.12);
+ padding: 0 var(--space-lg);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ position: sticky;
+ top: 0;
+ z-index: var(--z-fixed);
+}
+
+/* Animated glow underline on header */
+.lt-header::after {
+ content: '';
+ position: absolute;
+ bottom: -2px;
+ left: 0; right: 0;
+ height: 2px;
+ background: linear-gradient(
+ 90deg,
+ transparent 0%,
+ var(--terminal-green) 20%,
+ var(--terminal-green) 80%,
+ transparent 100%
+ );
+ box-shadow: var(--glow-green);
+}
+
+.lt-header-left {
+ display: flex;
+ align-items: center;
+ gap: var(--space-xl);
+}
+.lt-header-right {
+ display: flex;
+ align-items: center;
+ gap: var(--space-md);
+}
+
+/* --- Brand / App Title --- */
+.lt-brand { display: flex; flex-direction: column; }
+
+.lt-brand-title {
+ font-size: 1.2rem;
+ font-weight: bold;
+ color: var(--terminal-amber);
+ text-shadow: var(--glow-amber-intense);
+ letter-spacing: 0.1em;
+ white-space: nowrap;
+ animation: subtle-pulse 3s ease-in-out infinite;
+}
+.lt-brand-title::before {
+ content: '>> ';
+ color: var(--terminal-green);
+ text-shadow: var(--glow-green);
+}
+
+.lt-brand-subtitle {
+ font-size: 0.6rem;
+ color: var(--text-muted);
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+/* --- Nav links (horizontal bar) --- */
+.lt-nav {
+ display: flex;
+ align-items: center;
+ gap: var(--space-xs);
+}
+
+.lt-nav-link {
+ display: inline-block;
+ color: var(--text-muted);
+ padding: 4px 10px;
+ border: 1px solid transparent;
+ font-size: 0.8rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ transition: var(--transition-fast);
+ white-space: nowrap;
+}
+.lt-nav-link::before { content: '[ '; }
+.lt-nav-link::after { content: ' ]'; }
+
+.lt-nav-link:hover {
+ color: var(--terminal-green);
+ border-color: var(--border-color);
+ background: var(--terminal-green-dim);
+ text-shadow: var(--glow-green);
+}
+.lt-nav-link.active {
+ color: var(--terminal-amber);
+ border-color: var(--terminal-amber);
+ background: var(--terminal-amber-dim);
+ text-shadow: var(--glow-amber);
+}
+
+/* --- Dropdown nav --- */
+.lt-nav-dropdown { position: relative; }
+
+.lt-nav-dropdown-menu {
+ display: none;
+ position: absolute;
+ top: calc(100% + 4px);
+ left: 0;
+ min-width: 200px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color-hi);
+ box-shadow: var(--box-glow-green), 0 8px 24px rgba(0,0,0,0.6);
+ z-index: var(--z-dropdown);
+ list-style: none;
+}
+.lt-nav-dropdown:hover .lt-nav-dropdown-menu,
+.lt-nav-dropdown.open .lt-nav-dropdown-menu { display: block; }
+
+.lt-nav-dropdown-menu li a {
+ display: block;
+ padding: 8px 14px;
+ color: var(--text-secondary);
+ font-size: 0.8rem;
+ border-bottom: 1px solid var(--border-color-dim);
+ transition: var(--transition-fast);
+}
+.lt-nav-dropdown-menu li a::before { content: '> '; color: var(--terminal-amber); }
+.lt-nav-dropdown-menu li a:hover {
+ background: var(--terminal-green-dim);
+ color: var(--terminal-green);
+ text-shadow: var(--glow-green);
+}
+.lt-nav-dropdown-menu li:last-child a { border-bottom: none; }
+
+/* --- User info widget --- */
+.lt-header-user {
+ font-size: 0.78rem;
+ color: var(--text-secondary);
+ white-space: nowrap;
+}
+.lt-header-user::before { content: '[user: '; color: var(--terminal-amber); }
+.lt-header-user::after { content: ']'; color: var(--terminal-amber); }
+
+/* --- Admin badge --- */
+.lt-badge-admin {
+ display: inline-block;
+ color: var(--terminal-red);
+ border: 1px solid var(--terminal-red);
+ padding: 2px 8px;
+ font-size: 0.7rem;
+ font-weight: bold;
+ text-shadow: var(--glow-red);
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+}
+.lt-badge-admin::before { content: '['; }
+.lt-badge-admin::after { content: ']'; }
+
+/* ----------------------------------------------------------------
+ 07. ASCII FRAME SYSTEM
+ ---------------------------------------------------------------- */
+
+/* Outer frame — double-line border, for major page sections */
+.lt-frame {
+ position: relative;
+ border: 3px double var(--terminal-green);
+ background: var(--bg-primary);
+ margin-bottom: var(--space-xl);
+ animation: boot-up 0.8s ease-out;
+}
+
+.lt-frame::before {
+ content: '╔';
+ position: absolute;
+ top: -5px; left: -5px;
+ font-size: 1.3rem;
+ color: var(--terminal-green);
+ text-shadow: var(--glow-green);
+ animation: corner-pulse 3s ease-in-out infinite;
+ line-height: 1;
+}
+.lt-frame::after {
+ content: '╗';
+ position: absolute;
+ top: -5px; right: -5px;
+ font-size: 1.3rem;
+ color: var(--terminal-green);
+ text-shadow: var(--glow-green);
+ animation: corner-pulse 3s ease-in-out infinite;
+ line-height: 1;
+}
+
+/* Bottom corners require explicit child elements:
+ ╚
+ ╝ */
+.lt-frame-bl,
+.lt-frame-br {
+ position: absolute;
+ bottom: -5px;
+ font-size: 1.3rem;
+ color: var(--terminal-green);
+ text-shadow: var(--glow-green);
+ line-height: 1;
+}
+.lt-frame-bl { left: -5px; }
+.lt-frame-br { right: -5px; }
+
+/* Inner frame — single-line, for sub-panels inside a .lt-frame */
+.lt-frame-inner {
+ position: relative;
+ border: 2px solid var(--terminal-green);
+ background: var(--bg-secondary);
+ padding: var(--space-md);
+ margin: var(--space-md);
+}
+.lt-frame-inner::before {
+ content: '┌';
+ position: absolute;
+ top: -3px; left: -3px;
+ color: var(--terminal-green);
+ line-height: 1;
+}
+.lt-frame-inner::after {
+ content: '┐';
+ position: absolute;
+ top: -3px; right: -3px;
+ color: var(--terminal-green);
+ line-height: 1;
+}
+
+/* Section header — full-width amber title bar inside a frame */
+.lt-section-header {
+ padding: 0.75rem 1.5rem;
+ background: var(--bg-primary);
+ border-bottom: 2px solid var(--terminal-green);
+ font-weight: bold;
+ font-size: 0.9rem;
+ color: var(--terminal-amber);
+ text-shadow: var(--glow-amber-intense);
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ animation: subtle-pulse 3s ease-in-out infinite;
+}
+.lt-section-header::before {
+ content: '╠═══ ';
+ color: var(--terminal-green);
+ text-shadow: var(--glow-green);
+}
+.lt-section-header::after {
+ content: ' ═══╣';
+ color: var(--terminal-green);
+ text-shadow: var(--glow-green);
+}
+
+/* Subsection header */
+.lt-subsection-header {
+ padding: 0.5rem var(--space-md);
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--terminal-green);
+ font-weight: bold;
+ font-size: 0.85rem;
+ color: var(--terminal-green);
+ text-transform: uppercase;
+ letter-spacing: 0.07em;
+}
+.lt-subsection-header::before { content: '├─── '; }
+.lt-subsection-header::after { content: ' ───┤'; }
+
+/* Horizontal divider with ASCII end-caps */
+.lt-divider {
+ height: 1px;
+ background: var(--border-color);
+ margin: var(--space-lg) 0;
+ position: relative;
+}
+.lt-divider::before {
+ content: '╞═══';
+ position: absolute;
+ left: -2px; top: -10px;
+ font-size: 0.7rem;
+ color: var(--text-muted);
+ line-height: 1;
+}
+.lt-divider::after {
+ content: '═══╡';
+ position: absolute;
+ right: -2px; top: -10px;
+ font-size: 0.7rem;
+ color: var(--text-muted);
+ line-height: 1;
+}
+
+/* Content body inside a frame / section */
+.lt-section-body { padding: var(--space-md) var(--space-lg); }
+
+/* ----------------------------------------------------------------
+ 08. CARD COMPONENT
+ ---------------------------------------------------------------- */
+.lt-card {
+ position: relative;
+ background: var(--bg-secondary);
+ border: 2px solid var(--border-color);
+ padding: var(--space-lg);
+ transition: var(--transition-default);
+}
+.lt-card::before {
+ content: '┌';
+ position: absolute;
+ top: -3px; left: -3px;
+ color: var(--terminal-green);
+ line-height: 1;
+}
+.lt-card::after {
+ content: '┐';
+ position: absolute;
+ top: -3px; right: -3px;
+ color: var(--terminal-green);
+ line-height: 1;
+}
+.lt-card:hover {
+ border-color: var(--terminal-green);
+ box-shadow: var(--box-glow-green);
+}
+
+.lt-card-title {
+ font-size: 0.95rem;
+ color: var(--terminal-amber);
+ text-shadow: var(--glow-amber);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ margin-bottom: var(--space-md);
+ padding-bottom: var(--space-sm);
+ border-bottom: 1px solid var(--border-color);
+}
+.lt-card-title::before { content: '═══ '; color: var(--terminal-green); }
+.lt-card-title::after { content: ' ═══'; color: var(--terminal-green); }
+
+/* Card grid wrappers */
+.lt-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: var(--space-md); }
+.lt-grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-md); }
+.lt-grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-md); }
+.lt-grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--space-md); }
+
+/* ----------------------------------------------------------------
+ 09. BUTTONS
+ ---------------------------------------------------------------- */
+.lt-btn {
+ display: inline-flex;
+ align-items: center;
+ background: transparent;
+ color: var(--terminal-green);
+ border: 2px solid var(--terminal-green);
+ border-radius: 0; /* NO rounded corners — terminal style */
+ padding: 10px 20px;
+ cursor: pointer;
+ font-family: var(--font-mono);
+ font-size: 0.9rem;
+ font-weight: bold;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ text-decoration: none;
+ transition: var(--transition-default);
+ white-space: nowrap;
+ user-select: none;
+}
+.lt-btn::before { content: '[ '; flex-shrink: 0; }
+.lt-btn::after { content: ' ]'; flex-shrink: 0; }
+
+.lt-btn:hover {
+ background: var(--terminal-green-dim);
+ color: var(--terminal-amber);
+ border-color: var(--terminal-amber);
+ text-shadow: var(--glow-amber-intense);
+ box-shadow: var(--box-glow-amber);
+ transform: translateY(-2px);
+}
+.lt-btn:active {
+ transform: translateY(0);
+ box-shadow: var(--box-glow-green);
+}
+.lt-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ transform: none;
+ box-shadow: none;
+}
+
+/* Amber (primary call-to-action) */
+.lt-btn-primary {
+ color: var(--terminal-amber);
+ border-color: var(--terminal-amber);
+ text-shadow: var(--glow-amber);
+}
+.lt-btn-primary::before { content: '> '; }
+.lt-btn-primary:hover {
+ background: var(--terminal-amber-dim);
+ box-shadow: var(--box-glow-amber);
+}
+
+/* Red (destructive / danger) */
+.lt-btn-danger {
+ color: var(--terminal-red);
+ border-color: var(--terminal-red);
+}
+.lt-btn-danger:hover {
+ background: var(--terminal-red-dim);
+ color: var(--terminal-red);
+ border-color: var(--terminal-red);
+ text-shadow: var(--glow-red);
+ box-shadow: var(--box-glow-red);
+}
+
+/* Small variant */
+.lt-btn-sm { padding: 5px 10px; font-size: 0.75rem; border-width: 1px; }
+
+/* Ghost (border only, no fill on hover either) */
+.lt-btn-ghost { border-color: var(--border-color); color: var(--text-muted); }
+
+/* Button group */
+.lt-btn-group { display: flex; gap: var(--space-sm); flex-wrap: wrap; align-items: center; }
+
+/* ----------------------------------------------------------------
+ 10. FORMS & INPUTS
+ ---------------------------------------------------------------- */
+.lt-form-group {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-xs);
+ margin-bottom: var(--space-md);
+}
+
+.lt-label {
+ font-size: 0.75rem;
+ font-weight: bold;
+ color: var(--terminal-amber);
+ text-transform: uppercase;
+ letter-spacing: 0.07em;
+ text-shadow: var(--glow-amber);
+}
+.lt-label-required::after { content: ' *'; color: var(--terminal-red); }
+
+.lt-input,
+.lt-textarea,
+.lt-select {
+ font-family: var(--font-mono);
+ font-size: 0.9rem;
+ background: var(--bg-primary);
+ color: var(--terminal-green);
+ border: 2px solid var(--terminal-green);
+ border-radius: 0;
+ padding: 10px var(--space-md);
+ width: 100%;
+ transition: var(--transition-fast);
+ box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.5);
+}
+.lt-input:focus,
+.lt-textarea:focus,
+.lt-select:focus {
+ outline: none;
+ border-color: var(--terminal-amber);
+ box-shadow: var(--glow-amber), inset 0 0 10px rgba(0, 0, 0, 0.5);
+ background: rgba(0, 255, 65, 0.03);
+ animation: focus-pulse 2s ease-in-out infinite;
+}
+.lt-input::placeholder,
+.lt-textarea::placeholder { color: var(--text-muted); opacity: 0.7; }
+
+.lt-textarea { min-height: 100px; resize: vertical; }
+
+.lt-select option {
+ background: var(--bg-secondary);
+ color: var(--terminal-green);
+}
+
+/* Checkbox */
+.lt-checkbox {
+ appearance: none;
+ width: 18px; height: 18px;
+ border: 2px solid var(--terminal-green);
+ background: var(--bg-primary);
+ cursor: pointer;
+ border-radius: 0;
+ flex-shrink: 0;
+ transition: var(--transition-fast);
+}
+.lt-checkbox:checked {
+ background: var(--terminal-green-dim);
+ border-color: var(--terminal-amber);
+}
+.lt-checkbox:checked::before {
+ content: '✓';
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%; height: 100%;
+ color: var(--terminal-amber);
+ font-size: 0.8rem;
+ line-height: 1;
+}
+
+/* Search input with prompt prefix */
+.lt-search { position: relative; }
+.lt-search-input { padding-left: 2.2rem; }
+.lt-search::before {
+ content: '>';
+ position: absolute;
+ left: 10px;
+ top: 50%; transform: translateY(-50%);
+ color: var(--terminal-amber);
+ font-weight: bold;
+ pointer-events: none;
+ z-index: 1;
+}
+
+/* Form hint */
+.lt-form-hint {
+ font-size: 0.7rem;
+ color: var(--text-muted);
+ margin-top: var(--space-xs);
+}
+
+/* Toolbar row */
+.lt-toolbar {
+ display: flex;
+ align-items: center;
+ gap: var(--space-md);
+ flex-wrap: wrap;
+ margin-bottom: var(--space-md);
+ justify-content: space-between;
+}
+.lt-toolbar-left,
+.lt-toolbar-right { display: flex; align-items: center; gap: var(--space-sm); }
+
+/* ----------------------------------------------------------------
+ 11. TABLES
+ ---------------------------------------------------------------- */
+
+/* Standard table — full-grid borders, good for simple data */
+.lt-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-family: var(--font-mono);
+ background: var(--bg-secondary);
+ border: 2px solid var(--terminal-green);
+}
+.lt-table th {
+ background: var(--bg-primary);
+ padding: 10px 14px;
+ font-size: 0.8rem;
+ font-weight: bold;
+ color: var(--terminal-amber);
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ text-shadow: var(--glow-amber);
+ border: 1px solid var(--border-color);
+ border-bottom: 2px solid var(--terminal-green);
+ white-space: nowrap;
+}
+.lt-table th::before { content: '> '; color: var(--terminal-green); }
+.lt-table th:has(input[type="checkbox"])::before { content: none; }
+
+.lt-table td {
+ padding: 10px 14px;
+ font-size: 0.88rem;
+ color: var(--text-primary);
+ border: 1px solid var(--border-color-dim);
+ word-break: break-word;
+}
+.lt-table tbody tr:hover {
+ background: rgba(0, 255, 65, 0.06);
+ box-shadow: inset 0 0 20px rgba(0, 255, 65, 0.05);
+}
+
+/* Data table — compact, row-only separators, good for dense data */
+.lt-data-table { width: 100%; border-collapse: collapse; font-family: var(--font-mono); }
+.lt-data-table th {
+ padding: 8px 12px;
+ font-size: 0.72rem;
+ font-weight: bold;
+ color: var(--terminal-amber);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ text-shadow: var(--glow-amber);
+ background: var(--bg-tertiary);
+ border-bottom: 1px solid var(--border-color);
+}
+.lt-data-table th::before { content: '> '; color: var(--terminal-green); }
+.lt-data-table td {
+ padding: 7px 12px;
+ font-size: 0.85rem;
+ border-bottom: 1px solid rgba(0, 255, 65, 0.08);
+}
+.lt-data-table tbody tr:hover { background: rgba(0, 255, 65, 0.05); }
+
+/* Priority left-border accent (apply to
) */
+.lt-table tbody tr td:first-child,
+.lt-data-table tbody tr td:first-child { border-left: 4px solid transparent; }
+.lt-row-p1 td:first-child { border-left-color: var(--priority-1) !important; }
+.lt-row-p2 td:first-child { border-left-color: var(--priority-2) !important; }
+.lt-row-p3 td:first-child { border-left-color: var(--priority-3) !important; }
+.lt-row-p4 td:first-child { border-left-color: var(--priority-4) !important; }
+.lt-row-p5 td:first-child { border-left-color: var(--priority-5) !important; }
+
+/* Row state tints */
+.lt-row-critical td { background: rgba(255, 68, 68, 0.03); }
+.lt-row-warning td { background: rgba(255, 140, 0, 0.03); }
+.lt-row-success td { background: rgba(40, 167, 69, 0.03); }
+
+/* Keyboard-selected row (vim-style navigation) */
+.lt-row-selected {
+ background: rgba(0, 255, 65, 0.12) !important;
+ outline: 2px solid var(--terminal-green);
+ outline-offset: -2px;
+}
+
+/* Scrollable table wrapper */
+.lt-table-wrap { overflow-x: auto; border: 2px solid var(--terminal-green); }
+
+/* Sortable column header */
+.lt-table th[data-sort] { cursor: pointer; }
+.lt-table th[data-sort]:hover { color: var(--terminal-green); text-shadow: var(--glow-green); }
+.lt-table th[data-sort="asc"]::after { content: ' ▲'; color: var(--terminal-green); }
+.lt-table th[data-sort="desc"]::after { content: ' ▼'; color: var(--terminal-green); }
+
+/* ----------------------------------------------------------------
+ 12. STATUS BADGES, CHIPS, PRIORITY
+ ---------------------------------------------------------------- */
+
+/* Full status badge */
+.lt-status {
+ display: inline-block;
+ padding: 4px 12px;
+ border: 2px solid;
+ font-family: var(--font-mono);
+ font-size: 0.82rem;
+ font-weight: bold;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ white-space: nowrap;
+}
+.lt-status::before { content: '['; }
+.lt-status::after { content: ']'; }
+
+.lt-status-open { color: var(--status-open); border-color: var(--status-open); text-shadow: 0 0 5px var(--status-open), 0 0 10px var(--status-open); }
+.lt-status-open::before { content: '[●'; }
+.lt-status-pending { color: var(--status-pending); border-color: var(--status-pending); }
+.lt-status-pending::before { content: '[○'; }
+.lt-status-in-progress { color: var(--status-in-progress); border-color: var(--status-in-progress); }
+.lt-status-in-progress::before { content: '[◐'; animation: spin-status 2s linear infinite; }
+.lt-status-closed { color: var(--status-closed); border-color: var(--status-closed); }
+.lt-status-closed::before { content: '[✓'; }
+.lt-status-online { color: var(--status-online); border-color: var(--status-online); text-shadow: 0 0 5px var(--status-online); }
+.lt-status-online::before { content: '[●'; }
+.lt-status-offline { color: var(--status-offline); border-color: var(--status-offline); text-shadow: 0 0 5px var(--status-offline); animation: pulse-red 1.5s infinite; }
+.lt-status-offline::before { content: '[○'; }
+.lt-status-running { color: var(--status-running); border-color: var(--status-running); }
+.lt-status-running::before { content: '[◐'; animation: spin-status 2s linear infinite; }
+.lt-status-completed { color: var(--status-completed); border-color: var(--status-completed); }
+.lt-status-completed::before { content: '[✓'; }
+.lt-status-failed { color: var(--status-failed); border-color: var(--status-failed); text-shadow: 0 0 5px var(--status-failed); }
+.lt-status-failed::before { content: '[✗'; }
+
+/* Chip (small, compact status tag) */
+.lt-chip {
+ display: inline-block;
+ padding: 2px 8px;
+ border: 1px solid;
+ font-size: 0.75rem;
+ font-weight: bold;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+.lt-chip::before { content: '['; }
+.lt-chip::after { content: ']'; }
+.lt-chip-ok { color: var(--terminal-green); border-color: var(--terminal-green); text-shadow: var(--glow-green); }
+.lt-chip-warn { color: var(--terminal-orange); border-color: var(--terminal-orange); }
+.lt-chip-critical { color: var(--terminal-red); border-color: var(--terminal-red); text-shadow: var(--glow-red); animation: pulse-glow 2s infinite; }
+.lt-chip-info { color: var(--terminal-cyan); border-color: var(--terminal-cyan); text-shadow: var(--glow-cyan); }
+
+/* Badge (inline label, smallest) */
+.lt-badge {
+ display: inline-block;
+ padding: 1px 6px;
+ border: 1px solid;
+ font-size: 0.7rem;
+ font-weight: bold;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+}
+.lt-badge::before { content: '['; }
+.lt-badge::after { content: ']'; }
+.lt-badge-green { color: var(--terminal-green); border-color: var(--terminal-green); }
+.lt-badge-amber { color: var(--terminal-amber); border-color: var(--terminal-amber); }
+.lt-badge-red { color: var(--terminal-red); border-color: var(--terminal-red); }
+.lt-badge-cyan { color: var(--terminal-cyan); border-color: var(--terminal-cyan); }
+.lt-badge-muted { color: var(--text-muted); border-color: var(--border-color); }
+
+/* Priority badge */
+.lt-priority {
+ display: inline-block;
+ padding: 3px 10px;
+ border: 1px solid;
+ font-size: 0.78rem;
+ font-weight: bold;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+.lt-priority::before { content: '['; }
+.lt-priority::after { content: ']'; }
+.lt-p1 { color: var(--priority-1); border-color: var(--priority-1); text-shadow: 0 0 5px var(--priority-1); animation: pulse-glow 2s infinite; }
+.lt-p1::before { content: '[▲▲ P1 CRITICAL'; }
+.lt-p2 { color: var(--priority-2); border-color: var(--priority-2); }
+.lt-p2::before { content: '[▲ P2 HIGH'; }
+.lt-p3 { color: var(--priority-3); border-color: var(--priority-3); }
+.lt-p3::before { content: '[● P3 MED'; }
+.lt-p4 { color: var(--priority-4); border-color: var(--priority-4); }
+.lt-p4::before { content: '[▼ P4 LOW'; }
+.lt-p5 { color: var(--priority-5); border-color: var(--priority-5); }
+.lt-p5::before { content: '[▼▼ P5 MIN'; }
+
+/* Inline status dot */
+.lt-dot {
+ display: inline-block;
+ width: 8px; height: 8px;
+ border-radius: 50%;
+ border: 2px solid;
+ flex-shrink: 0;
+}
+.lt-dot-up { background: var(--terminal-green); border-color: var(--terminal-green); box-shadow: 0 0 4px var(--terminal-green); }
+.lt-dot-down { background: var(--terminal-red); border-color: var(--terminal-red); animation: pulse-red 1.5s infinite; }
+.lt-dot-warn { background: var(--terminal-orange); border-color: var(--terminal-orange); }
+.lt-dot-idle { background: transparent; border-color: var(--text-muted); }
+
+/* ----------------------------------------------------------------
+ 13. MODALS
+ ---------------------------------------------------------------- */
+.lt-modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.88);
+ z-index: var(--z-modal-backdrop);
+ display: none;
+ align-items: center;
+ justify-content: center;
+ backdrop-filter: blur(2px);
+}
+.lt-modal-overlay.show { display: flex; }
+
+.lt-modal {
+ position: relative;
+ background: var(--bg-secondary);
+ border: 3px double var(--terminal-green);
+ box-shadow: 0 0 30px rgba(0, 255, 65, 0.2), 0 8px 40px rgba(0, 0, 0, 0.8);
+ width: 560px;
+ max-width: 95vw;
+ max-height: 90vh;
+ overflow-y: auto;
+ animation: modal-slide-in 0.3s ease;
+}
+
+/* Double-line corner decorations */
+.lt-modal::before {
+ content: '╔';
+ position: absolute;
+ top: -5px; left: -5px;
+ font-size: 1.2rem;
+ color: var(--terminal-green);
+ text-shadow: var(--glow-green);
+ line-height: 1;
+}
+.lt-modal::after {
+ content: '╗';
+ position: absolute;
+ top: -5px; right: -5px;
+ font-size: 1.2rem;
+ color: var(--terminal-green);
+ text-shadow: var(--glow-green);
+ line-height: 1;
+}
+
+.lt-modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--space-md) var(--space-lg);
+ border-bottom: 2px solid var(--terminal-green);
+ background: var(--bg-primary);
+}
+
+.lt-modal-title {
+ font-size: 0.9rem;
+ font-weight: bold;
+ color: var(--terminal-amber);
+ text-shadow: var(--glow-amber);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+.lt-modal-title::before { content: '>> '; color: var(--terminal-green); }
+
+.lt-modal-close {
+ background: transparent;
+ border: 1px solid var(--border-color);
+ color: var(--text-muted);
+ cursor: pointer;
+ font-family: var(--font-mono);
+ font-size: 0.85rem;
+ padding: 2px 8px;
+ transition: var(--transition-fast);
+}
+.lt-modal-close::before,
+.lt-modal-close::after { content: ''; } /* Override global btn brackets */
+.lt-modal-close:hover { color: var(--terminal-red); border-color: var(--terminal-red); text-shadow: var(--glow-red); transform: none; }
+
+.lt-modal-body { padding: var(--space-lg); }
+.lt-modal-footer {
+ display: flex;
+ gap: var(--space-sm);
+ justify-content: flex-end;
+ padding: var(--space-md) var(--space-lg);
+ border-top: 1px solid var(--border-color);
+ background: var(--bg-primary);
+}
+
+/* Wide variant */
+.lt-modal-lg { width: 800px; }
+
+/* ----------------------------------------------------------------
+ 14. TOAST NOTIFICATIONS
+ ---------------------------------------------------------------- */
+.lt-toast-container {
+ position: fixed;
+ bottom: var(--space-lg);
+ right: var(--space-lg);
+ z-index: var(--z-toast);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-sm);
+ pointer-events: none;
+}
+
+.lt-toast {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ padding: 10px 16px;
+ background: var(--bg-secondary);
+ border: 2px solid;
+ font-family: var(--font-mono);
+ font-size: 0.85rem;
+ font-weight: bold;
+ max-width: 420px;
+ pointer-events: all;
+ animation: slide-in-right 0.2s ease-out;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6);
+}
+.lt-toast::before { content: '>> '; flex-shrink: 0; }
+.lt-toast-success { color: var(--terminal-green); border-color: var(--terminal-green); text-shadow: var(--glow-green); }
+.lt-toast-error { color: var(--terminal-red); border-color: var(--terminal-red); text-shadow: var(--glow-red); }
+.lt-toast-warning { color: var(--terminal-amber); border-color: var(--terminal-amber); text-shadow: var(--glow-amber); }
+.lt-toast-info { color: var(--terminal-cyan); border-color: var(--terminal-cyan); text-shadow: var(--glow-cyan); }
+
+.lt-toast-icon { font-size: 1rem; flex-shrink: 0; }
+.lt-toast-msg { flex: 1; }
+.lt-toast-close {
+ background: transparent; border: none;
+ color: inherit; cursor: pointer;
+ font-family: var(--font-mono);
+ opacity: 0.6; font-size: 0.85rem;
+ padding: 0 0 0 var(--space-sm);
+}
+.lt-toast-close::before,
+.lt-toast-close::after { content: ''; }
+.lt-toast-close:hover { opacity: 1; transform: none; }
+
+/* ----------------------------------------------------------------
+ 15. TAB NAVIGATION
+ ---------------------------------------------------------------- */
+.lt-tabs {
+ display: flex;
+ gap: var(--space-sm);
+ flex-wrap: wrap;
+ background: var(--bg-secondary);
+ border: 2px solid var(--border-color);
+ padding: var(--space-sm);
+ margin-bottom: var(--space-lg);
+}
+
+.lt-tab {
+ display: inline-flex;
+ align-items: center;
+ padding: 8px 18px;
+ background: transparent;
+ color: var(--text-muted);
+ border: 1px solid var(--border-color);
+ cursor: pointer;
+ font-family: var(--font-mono);
+ font-size: 0.82rem;
+ font-weight: bold;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ transition: var(--transition-fast);
+}
+.lt-tab::before { content: '[ '; }
+.lt-tab::after { content: ' ]'; }
+.lt-tab:hover {
+ color: var(--terminal-green);
+ border-color: var(--border-color-hi);
+ background: var(--terminal-green-dim);
+}
+.lt-tab.active {
+ color: var(--terminal-amber);
+ border-color: var(--terminal-amber);
+ background: var(--terminal-amber-dim);
+ text-shadow: var(--glow-amber);
+}
+
+.lt-tab-panel { display: none; }
+.lt-tab-panel.active { display: block; }
+
+/* ----------------------------------------------------------------
+ 16. SIDEBAR / FILTER PANEL
+ ---------------------------------------------------------------- */
+.lt-sidebar {
+ width: var(--sidebar-width);
+ background: var(--bg-secondary);
+ border: 2px solid var(--terminal-green);
+ flex-shrink: 0;
+ overflow-y: auto;
+ max-height: calc(100vh - var(--header-height) - 2rem);
+ position: sticky;
+ top: calc(var(--header-height) + var(--space-md));
+}
+
+.lt-sidebar-header {
+ padding: var(--space-sm) var(--space-md);
+ background: var(--bg-primary);
+ border-bottom: 1px solid var(--border-color);
+ font-size: 0.75rem;
+ font-weight: bold;
+ color: var(--terminal-amber);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+.lt-sidebar-header::before { content: '╠═ '; color: var(--terminal-green); }
+
+.lt-sidebar-body { padding: var(--space-md); }
+
+.lt-filter-group { margin-bottom: var(--space-lg); }
+
+.lt-filter-label {
+ display: block;
+ font-size: 0.75rem;
+ font-weight: bold;
+ color: var(--terminal-amber);
+ text-transform: uppercase;
+ letter-spacing: 0.07em;
+ margin-bottom: var(--space-sm);
+ padding-bottom: var(--space-xs);
+ border-bottom: 1px solid var(--border-color-dim);
+}
+
+.lt-filter-option {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ padding: var(--space-xs) 0;
+ cursor: pointer;
+ color: var(--text-primary);
+ font-size: 0.85rem;
+ transition: var(--transition-fast);
+}
+.lt-filter-option:hover { color: var(--terminal-amber); }
+
+/* Collapse toggle */
+.lt-sidebar.collapsed .lt-sidebar-body { display: none; }
+.lt-sidebar-toggle {
+ background: transparent;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ font-family: var(--font-mono);
+ font-size: 0.8rem;
+ padding: 0;
+}
+.lt-sidebar-toggle::before,
+.lt-sidebar-toggle::after { content: ''; }
+
+/* ----------------------------------------------------------------
+ 17. STATS WIDGETS
+ ---------------------------------------------------------------- */
+.lt-stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+ gap: var(--space-md);
+ margin-bottom: var(--space-lg);
+}
+
+.lt-stat-card {
+ background: var(--bg-secondary);
+ border: 2px solid var(--border-color);
+ padding: var(--space-md);
+ display: flex;
+ align-items: center;
+ gap: var(--space-md);
+ position: relative;
+ transition: var(--transition-default);
+ cursor: pointer;
+}
+.lt-stat-card::before {
+ content: '┌';
+ position: absolute;
+ top: -3px; left: -3px;
+ color: var(--terminal-green);
+ line-height: 1;
+}
+.lt-stat-card::after {
+ content: '┐';
+ position: absolute;
+ top: -3px; right: -3px;
+ color: var(--terminal-green);
+ line-height: 1;
+}
+.lt-stat-card:hover {
+ border-color: var(--terminal-amber);
+ box-shadow: var(--box-glow-amber);
+ transform: translateY(-2px);
+}
+.lt-stat-card.active {
+ background: var(--terminal-amber-dim);
+ border-color: var(--terminal-amber);
+}
+
+.lt-stat-icon { font-size: 1.8rem; }
+.lt-stat-info { display: flex; flex-direction: column; gap: 2px; }
+.lt-stat-value {
+ font-size: 1.6rem;
+ font-weight: bold;
+ color: var(--terminal-green);
+ text-shadow: 0 0 4px currentColor;
+ line-height: 1;
+}
+.lt-stat-label {
+ font-size: 0.68rem;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.07em;
+}
+
+/* ----------------------------------------------------------------
+ 18. INLINE MESSAGES
+ ---------------------------------------------------------------- */
+.lt-msg {
+ padding: var(--space-md) var(--space-lg);
+ border: 2px solid;
+ font-family: var(--font-mono);
+ margin: var(--space-md) 0;
+}
+.lt-msg-error { color: var(--terminal-red); border-color: var(--terminal-red); background: var(--terminal-red-dim); }
+.lt-msg-success { color: var(--status-open); border-color: var(--status-open); background: rgba(40, 167, 69, 0.08); }
+.lt-msg-warning { color: var(--terminal-amber); border-color: var(--terminal-amber); background: var(--terminal-amber-dim); }
+.lt-msg-info { color: var(--terminal-cyan); border-color: var(--terminal-cyan); background: var(--terminal-cyan-dim); }
+.lt-msg-error::before { content: '[✗] ERROR: '; font-weight: bold; }
+.lt-msg-success::before { content: '[✓] SUCCESS: '; font-weight: bold; }
+.lt-msg-warning::before { content: '[!] WARNING: '; font-weight: bold; }
+.lt-msg-info::before { content: '[i] INFO: '; font-weight: bold; }
+
+/* ----------------------------------------------------------------
+ 19. LOADING & EMPTY STATES
+ ---------------------------------------------------------------- */
+.lt-loading {
+ text-align: center;
+ padding: var(--space-xl);
+ color: var(--terminal-green);
+}
+.lt-loading::before { content: '[ LOADING'; display: block; margin-bottom: var(--space-sm); }
+.lt-loading::after { content: '...'; animation: loading-dots 1.5s steps(4, end) infinite; }
+
+.lt-empty {
+ text-align: center;
+ padding: var(--space-xl) var(--space-lg);
+ color: var(--text-muted);
+}
+.lt-empty::before {
+ content: '[ NO DATA ]';
+ display: block;
+ font-size: 1.1rem;
+ color: var(--terminal-green);
+ text-shadow: var(--glow-green);
+ margin-bottom: var(--space-sm);
+}
+
+/* Skeleton shimmer for async content */
+.lt-skeleton {
+ background: linear-gradient(
+ 90deg,
+ rgba(0, 255, 65, 0.04) 0%,
+ rgba(0, 255, 65, 0.10) 50%,
+ rgba(0, 255, 65, 0.04) 100%
+ );
+ background-size: 200% 100%;
+ animation: skeleton-shimmer 1.5s ease-in-out infinite;
+ border: 1px solid var(--border-color-dim);
+ color: transparent;
+ user-select: none;
+}
+
+/* ----------------------------------------------------------------
+ 20. BOOT SEQUENCE OVERLAY
+ ---------------------------------------------------------------- */
+.lt-boot-overlay {
+ position: fixed;
+ inset: 0;
+ background: var(--bg-primary);
+ z-index: 99999;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: opacity 0.5s ease;
+}
+.lt-boot-overlay.fade-out { opacity: 0; pointer-events: none; }
+
+.lt-boot-text {
+ font-family: var(--font-mono);
+ font-size: 0.9rem;
+ color: var(--terminal-green);
+ text-shadow: var(--glow-green);
+ line-height: 1.7;
+ white-space: pre;
+ padding: var(--space-xl);
+}
+
+/* ----------------------------------------------------------------
+ 21. LOG / TIMELINE ENTRIES
+ ---------------------------------------------------------------- */
+.lt-log-entry {
+ padding: var(--space-md);
+ background: var(--bg-secondary);
+ border-left: 3px solid var(--terminal-green);
+ margin-bottom: var(--space-sm);
+ font-size: 0.88rem;
+ transition: var(--transition-fast);
+}
+.lt-log-entry::before { content: '> '; color: var(--terminal-amber); font-weight: bold; }
+.lt-log-entry:hover { background: var(--terminal-green-dim); border-left-width: 5px; }
+.lt-log-entry.error { border-left-color: var(--terminal-red); }
+.lt-log-entry.warning { border-left-color: var(--terminal-amber); }
+.lt-log-entry.success { border-left-color: var(--status-open); }
+
+.lt-log-ts {
+ font-size: 0.78rem;
+ color: var(--terminal-amber);
+ margin-bottom: var(--space-xs);
+}
+
+.lt-log-output {
+ background: var(--bg-terminal);
+ border: 1px solid var(--border-color-dim);
+ padding: var(--space-sm);
+ margin-top: var(--space-sm);
+ max-height: 300px;
+ overflow-y: auto;
+ font-size: 0.82rem;
+}
+
+/* Timeline (vertical) */
+.lt-timeline { position: relative; padding-left: var(--space-xl); }
+.lt-timeline::before {
+ content: '';
+ position: absolute;
+ left: 8px; top: 0; bottom: 0;
+ width: 1px;
+ background: var(--border-color);
+}
+.lt-timeline-item {
+ position: relative;
+ margin-bottom: var(--space-md);
+}
+.lt-timeline-item::before {
+ content: '●';
+ position: absolute;
+ left: calc(-1 * var(--space-xl) + 2px);
+ color: var(--terminal-green);
+ font-size: 0.6rem;
+ top: 0.4rem;
+}
+
+/* ----------------------------------------------------------------
+ 22. ANIMATIONS
+ ---------------------------------------------------------------- */
+@keyframes scanline {
+ 0% { transform: translateY(0); }
+ 100% { transform: translateY(4px); }
+}
+
+@keyframes flicker {
+ 0% { opacity: 1; }
+ 10% { opacity: 0.95; }
+ 20% { opacity: 1; }
+ 30% { opacity: 0.97; }
+ 40% { opacity: 1; }
+}
+
+@keyframes data-stream {
+ 0% { content: '10101010'; opacity: 0.07; }
+ 25% { content: '01010101'; opacity: 0.10; }
+ 50% { content: '11001100'; opacity: 0.07; }
+ 75% { content: '00110011'; opacity: 0.10; }
+ 100% { content: '10101010'; opacity: 0.07; }
+}
+
+@keyframes boot-up {
+ 0% { opacity: 0; filter: brightness(2) saturate(0); }
+ 30% { opacity: 1; filter: brightness(1.5) saturate(0.5); }
+ 100% { opacity: 1; filter: brightness(1) saturate(1); }
+}
+
+@keyframes modal-slide-in {
+ from { transform: translateY(-30px); opacity: 0; }
+ to { transform: translateY(0); opacity: 1; }
+}
+
+@keyframes slide-in-right {
+ from { transform: translateX(30px); opacity: 0; }
+ to { transform: translateX(0); opacity: 1; }
+}
+
+@keyframes corner-pulse {
+ 0%, 100% { text-shadow: var(--glow-green); }
+ 50% { text-shadow: var(--glow-green-intense); }
+}
+
+@keyframes subtle-pulse {
+ 0%, 100% { text-shadow: var(--glow-amber); }
+ 50% { text-shadow: var(--glow-amber-intense); }
+}
+
+@keyframes pulse-glow {
+ 0%, 100% { text-shadow: 0 0 5px currentColor, 0 0 10px currentColor; }
+ 50% { text-shadow: 0 0 10px currentColor, 0 0 20px currentColor, 0 0 30px currentColor; }
+}
+
+@keyframes pulse-red {
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(255, 68, 68, 0.5); }
+ 50% { box-shadow: 0 0 6px 3px rgba(255, 68, 68, 0.2); }
+}
+
+@keyframes focus-pulse {
+ 0%, 100% { box-shadow: var(--glow-amber), inset 0 0 10px rgba(0, 0, 0, 0.5); }
+ 50% { box-shadow: var(--glow-amber-intense), inset 0 0 10px rgba(0, 0, 0, 0.5); }
+}
+
+@keyframes spin-status {
+ 0% { content: '[◐'; }
+ 25% { content: '[◓'; }
+ 50% { content: '[◑'; }
+ 75% { content: '[◒'; }
+ 100% { content: '[◐'; }
+}
+
+@keyframes spin-full {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
+
+@keyframes loading-dots {
+ 0%, 20% { content: '.'; }
+ 40% { content: '..'; }
+ 60%, 100% { content: '...'; }
+}
+
+@keyframes skeleton-shimmer {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
+@keyframes cursor-blink {
+ 0%, 50% { opacity: 1; }
+ 51%, 100% { opacity: 0; }
+}
+
+/* Item pulse for actively running tasks */
+@keyframes exec-running-pulse {
+ 0%, 100% { border-color: var(--terminal-green); }
+ 50% { border-color: var(--status-running); box-shadow: 0 0 8px rgba(255, 193, 7, 0.35); }
+}
+.lt-item-running { animation: exec-running-pulse 2s ease-in-out infinite; }
+
+/* ----------------------------------------------------------------
+ 23. RESPONSIVE DESIGN
+ ---------------------------------------------------------------- */
+@media (max-width: 1400px) {
+ .lt-grid-4 { grid-template-columns: repeat(3, 1fr); }
+ .lt-stats-grid { grid-template-columns: repeat(4, 1fr); }
+}
+
+@media (max-width: 1200px) {
+ .lt-sidebar { width: var(--sidebar-width-sm); }
+ .lt-grid-4, .lt-grid-3 { grid-template-columns: repeat(2, 1fr); }
+ .lt-stats-grid { grid-template-columns: repeat(3, 1fr); }
+}
+
+@media (max-width: 1024px) {
+ .lt-layout { flex-direction: column; }
+ .lt-sidebar { width: 100%; max-height: none; position: static; }
+ .lt-stats-grid { grid-template-columns: repeat(2, 1fr); }
+ .lt-grid-3 { grid-template-columns: repeat(2, 1fr); }
+}
+
+@media (max-width: 768px) {
+ :root { --header-height: 50px; }
+ .lt-header { padding: 0 var(--space-md); }
+ .lt-nav { display: none; } /* Replace with hamburger menu on mobile */
+ .lt-brand-subtitle { display: none; }
+ .lt-main { padding: var(--space-md); }
+ .lt-grid-2 { grid-template-columns: 1fr; }
+ .lt-stats-grid { grid-template-columns: repeat(2, 1fr); }
+ .lt-modal { width: 100%; max-width: none; margin: var(--space-md); }
+ .lt-table { font-size: 0.8rem; }
+ .lt-table th, .lt-table td { padding: 8px; }
+}
+
+@media (max-width: 480px) {
+ .lt-main { padding: var(--space-sm); }
+ .lt-stats-grid { grid-template-columns: 1fr 1fr; }
+ .lt-btn { padding: 8px 14px; font-size: 0.8rem; }
+ .lt-toast-container { left: var(--space-sm); right: var(--space-sm); bottom: var(--space-sm); }
+ .lt-toast { max-width: none; }
+}
+
+/* ----------------------------------------------------------------
+ 24. UTILITY CLASSES
+ ---------------------------------------------------------------- */
+.lt-text-green { color: var(--terminal-green) !important; text-shadow: var(--glow-green); }
+.lt-text-amber { color: var(--terminal-amber) !important; text-shadow: var(--glow-amber); }
+.lt-text-red { color: var(--terminal-red) !important; }
+.lt-text-cyan { color: var(--terminal-cyan) !important; }
+.lt-text-muted { color: var(--text-muted) !important; }
+.lt-text-mono { font-family: var(--font-mono) !important; }
+.lt-text-upper { text-transform: uppercase; letter-spacing: 0.06em; }
+.lt-text-sm { font-size: 0.8rem; }
+.lt-text-xs { font-size: 0.7rem; }
+.lt-no-wrap { white-space: nowrap; }
+
+.lt-flex { display: flex; align-items: center; }
+.lt-flex-between { display: flex; align-items: center; justify-content: space-between; }
+.lt-flex-wrap { flex-wrap: wrap; }
+.lt-gap-xs { gap: var(--space-xs); }
+.lt-gap-sm { gap: var(--space-sm); }
+.lt-gap-md { gap: var(--space-md); }
+.lt-mt-sm { margin-top: var(--space-sm); }
+.lt-mt-md { margin-top: var(--space-md); }
+.lt-mb-md { margin-bottom: var(--space-md); }
+.lt-mb-lg { margin-bottom: var(--space-lg); }
+.lt-p-md { padding: var(--space-md); }
+
+/* Screen reader only */
+.lt-sr-only {
+ position: absolute; width: 1px; height: 1px;
+ padding: 0; margin: -1px; overflow: hidden;
+ clip: rect(0,0,0,0); white-space: nowrap; border: 0;
+}
+
+/* ----------------------------------------------------------------
+ 25. PRINT STYLES
+ ---------------------------------------------------------------- */
+@media print {
+ body::before, body::after { display: none; }
+ .lt-header, .lt-sidebar, .lt-tabs,
+ .lt-modal-overlay, .lt-toast-container { display: none !important; }
+ .lt-main { padding: 0; }
+ body { background: white; color: black; animation: none; }
+ .lt-card, .lt-frame { border: 1px solid #333; box-shadow: none; }
+ a { color: black; }
+}
+
+/* ----------------------------------------------------------------
+ 26. ACCESSIBILITY
+ ---------------------------------------------------------------- */
+:focus-visible {
+ outline: 2px solid var(--terminal-amber);
+ outline-offset: 2px;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+ body::before { animation: none; }
+ body { animation: none; }
+}
diff --git a/assets/js/base.js b/assets/js/base.js
new file mode 100644
index 0000000..6caf677
--- /dev/null
+++ b/assets/js/base.js
@@ -0,0 +1,796 @@
+/**
+ * LOTUSGUILD TERMINAL DESIGN SYSTEM v1.0 — base.js
+ * Core JavaScript utilities shared across all LotusGuild applications
+ *
+ * Apps: Tinker Tickets (PHP), PULSE (Node.js), GANDALF (Flask)
+ * Namespace: window.lt
+ *
+ * CONTENTS
+ * 1. HTML Escape
+ * 2. Toast Notifications
+ * 3. Terminal Audio (beep)
+ * 4. Modal Management
+ * 5. Tab Management
+ * 6. Boot Sequence Animation
+ * 7. Keyboard Shortcuts
+ * 8. Sidebar Collapse
+ * 9. CSRF Token Helpers
+ * 10. Fetch Helpers (JSON API wrapper)
+ * 11. Time Formatting
+ * 12. Bytes Formatting
+ * 13. Table Keyboard Navigation
+ * 14. Sortable Table Headers
+ * 15. Stats Widget Filtering
+ * 16. Auto-refresh Manager
+ * 17. Initialisation
+ */
+
+(function (global) {
+ 'use strict';
+
+ /* ----------------------------------------------------------------
+ 1. HTML ESCAPE
+ ---------------------------------------------------------------- */
+ /**
+ * Escape a value for safe insertion into innerHTML.
+ * Always prefer textContent/innerText when possible, but use this
+ * when you must build HTML strings (e.g. template literals for lists).
+ *
+ * @param {*} str
+ * @returns {string}
+ */
+ function escHtml(str) {
+ if (str === null || str === undefined) return '';
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
+ /* ----------------------------------------------------------------
+ 2. TOAST NOTIFICATIONS
+ ----------------------------------------------------------------
+ Usage:
+ lt.toast.success('Ticket saved');
+ lt.toast.error('Network error', 5000);
+ lt.toast.warning('Rate limit approaching');
+ lt.toast.info('Workflow started');
+ ---------------------------------------------------------------- */
+ const _toastQueue = [];
+ let _toastActive = false;
+
+ /**
+ * @param {string} message
+ * @param {'success'|'error'|'warning'|'info'} type
+ * @param {number} [duration=3500] ms before auto-dismiss
+ */
+ function showToast(message, type, duration) {
+ type = type || 'info';
+ duration = duration || 3500;
+
+ if (_toastActive) {
+ _toastQueue.push({ message, type, duration });
+ return;
+ }
+ _displayToast(message, type, duration);
+ }
+
+ function _displayToast(message, type, duration) {
+ _toastActive = true;
+
+ let container = document.querySelector('.lt-toast-container');
+ if (!container) {
+ container = document.createElement('div');
+ container.className = 'lt-toast-container';
+ document.body.appendChild(container);
+ }
+
+ const icons = { success: '✓', error: '✗', warning: '!', info: 'i' };
+
+ const toast = document.createElement('div');
+ toast.className = 'lt-toast lt-toast-' + type;
+ toast.setAttribute('role', 'alert');
+ toast.setAttribute('aria-live', 'polite');
+
+ const iconEl = document.createElement('span');
+ iconEl.className = 'lt-toast-icon';
+ iconEl.textContent = '[' + (icons[type] || 'i') + ']';
+
+ const msgEl = document.createElement('span');
+ msgEl.className = 'lt-toast-msg';
+ msgEl.textContent = message;
+
+ const closeEl = document.createElement('button');
+ closeEl.className = 'lt-toast-close';
+ closeEl.textContent = '✕';
+ closeEl.setAttribute('aria-label', 'Dismiss');
+ closeEl.addEventListener('click', () => _dismissToast(toast));
+
+ toast.appendChild(iconEl);
+ toast.appendChild(msgEl);
+ toast.appendChild(closeEl);
+ container.appendChild(toast);
+
+ /* Auto-dismiss */
+ const timer = setTimeout(() => _dismissToast(toast), duration);
+ toast._lt_timer = timer;
+
+ /* Optional audio feedback */
+ _beep(type);
+ }
+
+ function _dismissToast(toast) {
+ if (!toast || !toast.parentNode) return;
+ clearTimeout(toast._lt_timer);
+ toast.style.opacity = '0';
+ toast.style.transition = 'opacity 0.3s ease';
+ setTimeout(() => {
+ if (toast.parentNode) toast.parentNode.removeChild(toast);
+ _toastActive = false;
+ if (_toastQueue.length) {
+ const next = _toastQueue.shift();
+ _displayToast(next.message, next.type, next.duration);
+ }
+ }, 320);
+ }
+
+ const toast = {
+ success: (msg, dur) => showToast(msg, 'success', dur),
+ error: (msg, dur) => showToast(msg, 'error', dur),
+ warning: (msg, dur) => showToast(msg, 'warning', dur),
+ info: (msg, dur) => showToast(msg, 'info', dur),
+ };
+
+ /* ----------------------------------------------------------------
+ 3. TERMINAL AUDIO
+ ----------------------------------------------------------------
+ Usage: lt.beep('success' | 'error' | 'info')
+ Silent-fails if Web Audio API is unavailable.
+ ---------------------------------------------------------------- */
+ function _beep(type) {
+ try {
+ const ctx = new (global.AudioContext || global.webkitAudioContext)();
+ const osc = ctx.createOscillator();
+ const gain = ctx.createGain();
+ osc.connect(gain);
+ gain.connect(ctx.destination);
+ osc.frequency.value = type === 'success' ? 880
+ : type === 'error' ? 220
+ : 440;
+ osc.type = 'sine';
+ gain.gain.setValueAtTime(0.08, ctx.currentTime);
+ gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.12);
+ osc.start(ctx.currentTime);
+ osc.stop(ctx.currentTime + 0.12);
+ } catch (_) { /* silently fail */ }
+ }
+
+ /* ----------------------------------------------------------------
+ 4. MODAL MANAGEMENT
+ ----------------------------------------------------------------
+ Usage:
+ lt.modal.open('my-modal-id');
+ lt.modal.close('my-modal-id');
+ lt.modal.closeAll();
+
+ HTML contract:
+
+ ---------------------------------------------------------------- */
+ function openModal(id) {
+ const el = typeof id === 'string' ? document.getElementById(id) : id;
+ if (!el) return;
+ el.classList.add('show');
+ el.setAttribute('aria-hidden', 'false');
+ document.body.style.overflow = 'hidden';
+ /* Focus first focusable element */
+ const first = el.querySelector('button, input, select, textarea, [tabindex]:not([tabindex="-1"])');
+ if (first) setTimeout(() => first.focus(), 50);
+ }
+
+ function closeModal(id) {
+ const el = typeof id === 'string' ? document.getElementById(id) : id;
+ if (!el) return;
+ el.classList.remove('show');
+ el.setAttribute('aria-hidden', 'true');
+ document.body.style.overflow = '';
+ }
+
+ function closeAllModals() {
+ document.querySelectorAll('.lt-modal-overlay.show').forEach(closeModal);
+ }
+
+ /* Delegated close handlers */
+ document.addEventListener('click', function (e) {
+ /* Click on overlay backdrop (outside .lt-modal) */
+ if (e.target.classList.contains('lt-modal-overlay')) {
+ closeModal(e.target);
+ return;
+ }
+ /* [data-modal-close] button */
+ const closeBtn = e.target.closest('[data-modal-close]');
+ if (closeBtn) {
+ const overlay = closeBtn.closest('.lt-modal-overlay');
+ if (overlay) closeModal(overlay);
+ }
+ /* [data-modal-open="id"] trigger */
+ const openBtn = e.target.closest('[data-modal-open]');
+ if (openBtn) openModal(openBtn.dataset.modalOpen);
+ });
+
+ const modal = { open: openModal, close: closeModal, closeAll: closeAllModals };
+
+ /* ----------------------------------------------------------------
+ 5. TAB MANAGEMENT
+ ----------------------------------------------------------------
+ Usage:
+ lt.tabs.init(); // auto-wires all .lt-tab elements
+ lt.tabs.switch('tab-panel-id');
+
+ HTML contract:
+
+
+
+
+ …
+ …
+
+ Persistence: localStorage key 'lt_activeTab_'
+ ---------------------------------------------------------------- */
+ function switchTab(panelId) {
+ document.querySelectorAll('.lt-tab').forEach(t => t.classList.remove('active'));
+ document.querySelectorAll('.lt-tab-panel').forEach(p => p.classList.remove('active'));
+
+ const btn = document.querySelector('.lt-tab[data-tab="' + panelId + '"]');
+ const panel = document.getElementById(panelId);
+ if (btn) btn.classList.add('active');
+ if (panel) panel.classList.add('active');
+
+ try { localStorage.setItem('lt_activeTab_' + location.pathname, panelId); } catch (_) {}
+ }
+
+ function initTabs() {
+ /* Restore from localStorage */
+ try {
+ const saved = localStorage.getItem('lt_activeTab_' + location.pathname);
+ if (saved && document.getElementById(saved)) { switchTab(saved); return; }
+ } catch (_) {}
+
+ /* Wire click handlers */
+ document.querySelectorAll('.lt-tab[data-tab]').forEach(btn => {
+ btn.addEventListener('click', () => switchTab(btn.dataset.tab));
+ });
+ }
+
+ const tabs = { init: initTabs, switch: switchTab };
+
+ /* ----------------------------------------------------------------
+ 6. BOOT SEQUENCE ANIMATION
+ ----------------------------------------------------------------
+ Usage:
+ lt.boot.run('APP NAME'); // shows once per session
+ lt.boot.run('APP NAME', true); // force show even if already seen
+
+ HTML contract (add to , hidden by default):
+
+ ---------------------------------------------------------------- */
+ function runBoot(appName, force) {
+ const storageKey = 'lt_booted_' + (appName || 'app');
+ if (!force && sessionStorage.getItem(storageKey)) return;
+
+ const overlay = document.getElementById('lt-boot');
+ const pre = document.getElementById('lt-boot-text');
+ if (!overlay || !pre) return;
+
+ overlay.style.display = 'flex';
+ overlay.style.opacity = '1';
+
+ const name = (appName || 'TERMINAL').toUpperCase();
+ const titleStr = name + ' v1.0';
+ const innerWidth = 43;
+ const leftPad = Math.max(0, Math.floor((innerWidth - titleStr.length) / 2));
+ const rightPad = Math.max(0, innerWidth - titleStr.length - leftPad);
+ const messages = [
+ '╔═══════════════════════════════════════════╗',
+ '║' + ' '.repeat(leftPad) + titleStr + ' '.repeat(rightPad) + '║',
+ '║ BOOTING SYSTEM... ║',
+ '╚═══════════════════════════════════════════╝',
+ '',
+ '[ OK ] Checking kernel modules...',
+ '[ OK ] Mounting filesystem...',
+ '[ OK ] Initializing database connection...',
+ '[ OK ] Loading user session...',
+ '[ OK ] Applying security headers...',
+ '[ OK ] Rendering terminal interface...',
+ '',
+ '> SYSTEM READY ✓',
+ '',
+ ];
+
+ let i = 0;
+ pre.textContent = '';
+ const interval = setInterval(() => {
+ if (i < messages.length) {
+ pre.textContent += messages[i] + '\n';
+ i++;
+ } else {
+ clearInterval(interval);
+ setTimeout(() => {
+ overlay.classList.add('fade-out');
+ setTimeout(() => {
+ overlay.style.display = 'none';
+ overlay.classList.remove('fade-out');
+ }, 520);
+ }, 400);
+ sessionStorage.setItem(storageKey, '1');
+ }
+ }, 80);
+ }
+
+ const boot = { run: runBoot };
+
+ /* ----------------------------------------------------------------
+ 7. KEYBOARD SHORTCUTS
+ ----------------------------------------------------------------
+ Register handlers:
+ lt.keys.on('ctrl+k', () => searchBox.focus());
+ lt.keys.on('?', showHelpModal);
+ lt.keys.on('Escape', lt.modal.closeAll);
+
+ Built-in defaults (activate with lt.keys.initDefaults()):
+ ESC → close all modals
+ ? → show #lt-keys-help modal if present
+ Ctrl/⌘+K → focus .lt-search-input
+ ---------------------------------------------------------------- */
+ const _keyHandlers = {};
+
+ function normalizeKey(combo) {
+ return combo
+ .replace(/ctrl\+/i, 'ctrl+')
+ .replace(/cmd\+/i, 'ctrl+') /* treat Cmd as Ctrl */
+ .replace(/meta\+/i, 'ctrl+')
+ .toLowerCase();
+ }
+
+ function registerKey(combo, handler) {
+ _keyHandlers[normalizeKey(combo)] = handler;
+ }
+
+ function unregisterKey(combo) {
+ delete _keyHandlers[normalizeKey(combo)];
+ }
+
+ document.addEventListener('keydown', function (e) {
+ const inInput = ['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)
+ || e.target.isContentEditable;
+
+ /* Build the combo string */
+ let combo = '';
+ if (e.ctrlKey || e.metaKey) combo += 'ctrl+';
+ if (e.altKey) combo += 'alt+';
+ if (e.shiftKey) combo += 'shift+';
+ combo += e.key.toLowerCase();
+
+ /* Always fire ESC, Ctrl combos regardless of input focus */
+ const alwaysFire = e.key === 'Escape' || e.ctrlKey || e.metaKey;
+
+ if (inInput && !alwaysFire) return;
+
+ const handler = _keyHandlers[combo];
+ if (handler) {
+ e.preventDefault();
+ handler(e);
+ }
+ });
+
+ function initDefaultKeys() {
+ registerKey('Escape', closeAllModals);
+ registerKey('?', () => {
+ const helpModal = document.getElementById('lt-keys-help');
+ if (helpModal) openModal(helpModal);
+ });
+ registerKey('ctrl+k', () => {
+ const search = document.querySelector('.lt-search-input');
+ if (search) { search.focus(); search.select(); }
+ });
+ }
+
+ const keys = {
+ on: registerKey,
+ off: unregisterKey,
+ initDefaults: initDefaultKeys,
+ };
+
+ /* ----------------------------------------------------------------
+ 8. SIDEBAR COLLAPSE
+ ----------------------------------------------------------------
+ Usage: lt.sidebar.init();
+
+ HTML contract:
+
+ ---------------------------------------------------------------- */
+ function initSidebar() {
+ document.querySelectorAll('[data-sidebar-toggle]').forEach(btn => {
+ const sidebar = document.getElementById(btn.dataset.sidebarToggle);
+ if (!sidebar) return;
+
+ /* Restore state */
+ const collapsed = sessionStorage.getItem('lt_sidebar_' + btn.dataset.sidebarToggle) === '1';
+ if (collapsed) {
+ sidebar.classList.add('collapsed');
+ btn.textContent = '▶';
+ }
+
+ btn.addEventListener('click', () => {
+ sidebar.classList.toggle('collapsed');
+ const isCollapsed = sidebar.classList.contains('collapsed');
+ btn.textContent = isCollapsed ? '▶' : '◀';
+ try { sessionStorage.setItem('lt_sidebar_' + btn.dataset.sidebarToggle, isCollapsed ? '1' : '0'); } catch (_) {}
+ });
+ });
+ }
+
+ const sidebar = { init: initSidebar };
+
+ /* ----------------------------------------------------------------
+ 9. CSRF TOKEN HELPERS
+ ----------------------------------------------------------------
+ PHP apps: window.CSRF_TOKEN is set by the view via:
+
+ Node apps: set via: window.CSRF_TOKEN = '<%= csrfToken %>';
+ Flask: use Flask-WTF meta tag or inject via template.
+
+ Usage:
+ const headers = lt.csrf.headers();
+ fetch('/api/foo', { method: 'POST', headers: lt.csrf.headers(), body: … });
+ ---------------------------------------------------------------- */
+ function csrfHeaders() {
+ const token = global.CSRF_TOKEN || '';
+ return token ? { 'X-CSRF-Token': token } : {};
+ }
+
+ const csrf = { headers: csrfHeaders };
+
+ /* ----------------------------------------------------------------
+ 10. FETCH HELPERS
+ ----------------------------------------------------------------
+ Usage:
+ const data = await lt.api.get('/api/tickets');
+ const res = await lt.api.post('/api/update_ticket.php', { id: 1, status: 'Closed' });
+ const res = await lt.api.delete('/api/ticket_dependencies.php', { id: 5 });
+
+ All methods:
+ - Automatically set Content-Type: application/json
+ - Attach CSRF token header
+ - Parse JSON response
+ - On non-2xx: throw an Error with the server's error message
+ ---------------------------------------------------------------- */
+ async function apiFetch(method, url, body) {
+ const opts = {
+ method,
+ headers: Object.assign(
+ { 'Content-Type': 'application/json' },
+ csrfHeaders()
+ ),
+ };
+ if (body !== undefined) opts.body = JSON.stringify(body);
+
+ let resp;
+ try {
+ resp = await fetch(url, opts);
+ } catch (networkErr) {
+ throw new Error('Network error: ' + networkErr.message);
+ }
+
+ let data;
+ try {
+ data = await resp.json();
+ } catch (_) {
+ data = { success: resp.ok };
+ }
+
+ if (!resp.ok) {
+ throw new Error(data.error || data.message || 'HTTP ' + resp.status);
+ }
+ return data;
+ }
+
+ const api = {
+ get: (url) => apiFetch('GET', url),
+ post: (url, body) => apiFetch('POST', url, body),
+ put: (url, body) => apiFetch('PUT', url, body),
+ patch: (url, body) => apiFetch('PATCH', url, body),
+ delete: (url, body) => apiFetch('DELETE', url, body),
+ };
+
+ /* ----------------------------------------------------------------
+ 11. TIME FORMATTING
+ ---------------------------------------------------------------- */
+ /**
+ * Returns a human-readable relative time string.
+ * @param {string|number|Date} value ISO string, Unix ms, or Date
+ * @returns {string} e.g. "5m ago", "2h ago", "3d ago"
+ */
+ function timeAgo(value) {
+ const date = value instanceof Date ? value : new Date(value);
+ if (isNaN(date)) return '—';
+ const diff = Math.floor((Date.now() - date.getTime()) / 1000);
+ if (diff < 60) return diff + 's ago';
+ if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
+ if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
+ return Math.floor(diff / 86400) + 'd ago';
+ }
+
+ /**
+ * Format seconds → "1h 23m 45s" style.
+ * @param {number} secs
+ * @returns {string}
+ */
+ function formatUptime(secs) {
+ secs = Math.floor(secs);
+ const d = Math.floor(secs / 86400);
+ const h = Math.floor((secs % 86400) / 3600);
+ const m = Math.floor((secs % 3600) / 60);
+ const s = secs % 60;
+ const parts = [];
+ if (d) parts.push(d + 'd');
+ if (h) parts.push(h + 'h');
+ if (m) parts.push(m + 'm');
+ if (!d) parts.push(s + 's');
+ return parts.join(' ') || '0s';
+ }
+
+ /**
+ * Format an ISO datetime string for display.
+ * Uses the timezone configured in window.APP_TIMEZONE (PHP apps)
+ * or falls back to the browser locale.
+ */
+ function formatDate(value) {
+ const date = value instanceof Date ? value : new Date(value);
+ if (isNaN(date)) return '—';
+ const tz = global.APP_TIMEZONE || undefined;
+ try {
+ return date.toLocaleString(undefined, {
+ timeZone: tz,
+ year: 'numeric', month: '2-digit', day: '2-digit',
+ hour: '2-digit', minute: '2-digit',
+ });
+ } catch (_) {
+ return date.toLocaleString();
+ }
+ }
+
+ const time = { ago: timeAgo, uptime: formatUptime, format: formatDate };
+
+ /* ----------------------------------------------------------------
+ 12. BYTES FORMATTING
+ ---------------------------------------------------------------- */
+ /**
+ * @param {number} bytes
+ * @returns {string} e.g. "1.23 GB"
+ */
+ function formatBytes(bytes) {
+ if (bytes === null || bytes === undefined) return '—';
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+ let i = 0;
+ while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; }
+ return bytes.toFixed(i === 0 ? 0 : 2) + ' ' + units[i];
+ }
+
+ /* ----------------------------------------------------------------
+ 13. TABLE KEYBOARD NAVIGATION (vim-style j/k)
+ ----------------------------------------------------------------
+ Usage: lt.tableNav.init('my-table-id');
+
+ Keys registered:
+ j or ArrowDown → move selection down
+ k or ArrowUp → move selection up
+ Enter → follow first in selected row
+ ---------------------------------------------------------------- */
+ function initTableNav(tableId) {
+ const table = tableId ? document.getElementById(tableId) : document.querySelector('.lt-table');
+ if (!table) return;
+
+ function rows() { return Array.from(table.querySelectorAll('tbody tr')); }
+ function selected() { return table.querySelector('tbody tr.lt-row-selected'); }
+
+ function move(dir) {
+ const all = rows();
+ if (!all.length) return;
+ const cur = selected();
+ const idx = cur ? all.indexOf(cur) : -1;
+ const next = dir === 'down'
+ ? all[idx < all.length - 1 ? idx + 1 : 0]
+ : all[idx > 0 ? idx - 1 : all.length - 1];
+ if (cur) cur.classList.remove('lt-row-selected');
+ next.classList.add('lt-row-selected');
+ next.scrollIntoView({ block: 'nearest' });
+ }
+
+ keys.on('j', () => move('down'));
+ keys.on('ArrowDown', () => move('down'));
+ keys.on('k', () => move('up'));
+ keys.on('ArrowUp', () => move('up'));
+ keys.on('Enter', () => {
+ const row = selected();
+ if (!row) return;
+ const link = row.querySelector('a[href]');
+ if (link) global.location.href = link.href;
+ });
+ }
+
+ const tableNav = { init: initTableNav };
+
+ /* ----------------------------------------------------------------
+ 14. SORTABLE TABLE HEADERS
+ ----------------------------------------------------------------
+ Usage: lt.sortTable.init('my-table-id');
+
+ Markup: add data-sort-key="field" to elements.
+ Sorts rows client-side by the text content of the matching column.
+ ---------------------------------------------------------------- */
+ function initSortTable(tableId) {
+ const table = document.getElementById(tableId);
+ if (!table) return;
+
+ const ths = Array.from(table.querySelectorAll('th[data-sort-key]'));
+ ths.forEach((th, colIdx) => {
+ th.style.cursor = 'pointer';
+ let dir = 'asc';
+
+ th.addEventListener('click', () => {
+ /* Reset all headers */
+ ths.forEach(h => h.removeAttribute('data-sort'));
+ th.setAttribute('data-sort', dir);
+
+ const tbody = table.querySelector('tbody');
+ const rows = Array.from(tbody.querySelectorAll('tr'));
+ rows.sort((a, b) => {
+ const aText = (a.cells[colIdx] || {}).textContent || '';
+ const bText = (b.cells[colIdx] || {}).textContent || '';
+ const n = !isNaN(parseFloat(aText)) && !isNaN(parseFloat(bText));
+ const cmp = n
+ ? parseFloat(aText) - parseFloat(bText)
+ : aText.localeCompare(bText);
+ return dir === 'asc' ? cmp : -cmp;
+ });
+ rows.forEach(r => tbody.appendChild(r));
+ dir = dir === 'asc' ? 'desc' : 'asc';
+ });
+ });
+ }
+
+ const sortTable = { init: initSortTable };
+
+ /* ----------------------------------------------------------------
+ 15. STATS WIDGET FILTERING
+ ----------------------------------------------------------------
+ Usage: lt.statsFilter.init();
+
+ HTML contract:
+ …
+
+ ---------------------------------------------------------------- */
+ function initStatsFilter() {
+ document.querySelectorAll('.lt-stat-card[data-filter-key]').forEach(card => {
+ card.addEventListener('click', () => {
+ const key = card.dataset.filterKey;
+ const val = card.dataset.filterVal;
+
+ /* Toggle active state */
+ const wasActive = card.classList.contains('active');
+ document.querySelectorAll('.lt-stat-card').forEach(c => c.classList.remove('active'));
+ if (!wasActive) card.classList.add('active');
+
+ /* Call app-specific filter hook if defined */
+ if (typeof global.lt_onStatFilter === 'function') {
+ global.lt_onStatFilter(wasActive ? null : key, wasActive ? null : val);
+ }
+ });
+ });
+ }
+
+ const statsFilter = { init: initStatsFilter };
+
+ /* ----------------------------------------------------------------
+ 16. AUTO-REFRESH MANAGER
+ ----------------------------------------------------------------
+ Usage:
+ lt.autoRefresh.start(refreshFn, 30000); // every 30 s
+ lt.autoRefresh.stop();
+ lt.autoRefresh.now(); // trigger immediately + restart timer
+ ---------------------------------------------------------------- */
+ let _arTimer = null;
+ let _arFn = null;
+ let _arInterval = 30000;
+
+ function arStart(fn, intervalMs) {
+ arStop();
+ _arFn = fn;
+ _arInterval = intervalMs || 30000;
+ _arTimer = setInterval(_arFn, _arInterval);
+ }
+
+ function arStop() {
+ if (_arTimer) { clearInterval(_arTimer); _arTimer = null; }
+ }
+
+ function arNow() {
+ arStop();
+ if (_arFn) {
+ _arFn();
+ _arTimer = setInterval(_arFn, _arInterval);
+ }
+ }
+
+ const autoRefresh = { start: arStart, stop: arStop, now: arNow };
+
+ /* ----------------------------------------------------------------
+ 17. INITIALISATION
+ ----------------------------------------------------------------
+ Called automatically on DOMContentLoaded.
+ Each sub-system can also be initialised manually after the DOM
+ has been updated with AJAX content.
+ ---------------------------------------------------------------- */
+ function init() {
+ initTabs();
+ initSidebar();
+ initDefaultKeys();
+ initStatsFilter();
+
+ /* Boot sequence: runs if #lt-boot element is present */
+ const bootEl = document.getElementById('lt-boot');
+ if (bootEl) {
+ const appName = bootEl.dataset.appName || document.title;
+ runBoot(appName);
+ }
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+ } else {
+ init();
+ }
+
+ /* ----------------------------------------------------------------
+ Public API
+ ---------------------------------------------------------------- */
+ global.lt = {
+ escHtml,
+ toast,
+ beep: _beep,
+ modal,
+ tabs,
+ boot,
+ keys,
+ sidebar,
+ csrf,
+ api,
+ time,
+ bytes: { format: formatBytes },
+ tableNav,
+ sortTable,
+ statsFilter,
+ autoRefresh,
+ };
+
+}(window));
diff --git a/deploy.sh b/deploy.sh
index 187309a..f3a9a71 100755
--- a/deploy.sh
+++ b/deploy.sh
@@ -3,17 +3,17 @@ set -e
echo "Deploying tinker_tickets to web server..."
-# Deploy web_template (shared UI framework)
+# Deploy web_template (shared UI framework) into the app directory so nginx serves it
echo "Syncing web_template to web server..."
-rsync -avz --delete --exclude='.git' --exclude='node' --exclude='php' --exclude='python' --exclude='README.md' --exclude='Claude.md' /root/code/web_template/ root@10.10.10.45:/var/www/html/web_template/
+rsync -avz --delete --exclude='.git' --exclude='node' --exclude='php' --exclude='python' --exclude='README.md' --exclude='Claude.md' /root/code/web_template/ root@10.10.10.45:/var/www/html/tinkertickets/web_template/
# Deploy to web server
echo "Syncing to web server (10.10.10.45)..."
-rsync -avz --delete --exclude='.git' --exclude='deploy.sh' --exclude='.env' ./ root@10.10.10.45:/var/www/html/tinkertickets/
+rsync -avz --delete --exclude='.git' --exclude='deploy.sh' --exclude='.env' --exclude='web_template' ./ root@10.10.10.45:/var/www/html/tinkertickets/
# Set proper permissions on the web server
echo "Setting proper file permissions..."
-ssh root@10.10.10.45 "chown -R www-data:www-data /var/www/html/web_template /var/www/html/tinkertickets && find /var/www/html/web_template /var/www/html/tinkertickets -type f -exec chmod 644 {} \; && find /var/www/html/web_template /var/www/html/tinkertickets -type d -exec chmod 755 {} \;"
+ssh root@10.10.10.45 "chown -R www-data:www-data /var/www/html/tinkertickets && find /var/www/html/tinkertickets -type f -exec chmod 644 {} \; && find /var/www/html/tinkertickets -type d -exec chmod 755 {} \;"
echo "Deployment to web server complete!"
echo "Don't forget to commit and push your changes via VS Code when ready."
\ No newline at end of file
diff --git a/views/CreateTicketView.php b/views/CreateTicketView.php
index 47204d6..819589a 100644
--- a/views/CreateTicketView.php
+++ b/views/CreateTicketView.php
@@ -11,10 +11,10 @@ $nonce = SecurityHeadersMiddleware::getNonce();
Create New Ticket
-
+
-
+
+
diff --git a/views/TicketView.php b/views/TicketView.php
index 53a348b..3caebfa 100644
--- a/views/TicketView.php
+++ b/views/TicketView.php
@@ -50,10 +50,10 @@ $nonce = SecurityHeadersMiddleware::getNonce();
Ticket #
-
+
-
+
diff --git a/views/admin/ApiKeysView.php b/views/admin/ApiKeysView.php
index 29d03e5..4f7a38b 100644
--- a/views/admin/ApiKeysView.php
+++ b/views/admin/ApiKeysView.php
@@ -12,10 +12,10 @@ $nonce = SecurityHeadersMiddleware::getNonce();
API Keys - Admin
-
+
-
+
+
|