fix: accessibility & quality audit pass 4+5

CSS:
- Add :active/:focus-visible to .lt-modal-close, .lt-drawer-right-close,
  .lt-notif-panel-clear, .lt-file-item-remove
- Add :focus-visible to .lt-accordion-header, .lt-tag-remove,
  .lt-combobox-tag-remove
- Add .lt-cmd-input-wrap:focus-within focus indicator (outline:none compensation)
- Add will-change: stroke-dashoffset to .lt-gauge-fill
- Add range slider :focus-visible thumb ring
- Fix .tok-cmt hardcoded #5c8c6a → var(--color-tok-cmt) w/ light-mode override
- Add .lt-skip-link component (visible on focus)
- Fix .lt-filter-group fieldset UA border reset

JS:
- Fix infinite scroll: store throttled handler ref so removeEventListener works
- Fix right drawer: remove close-button listeners in _rdClose (were never removed)
- Fix right drawer: add Tab focus trap (matches modal behaviour)
- Fix _cmdPaletteClose: restore focus to element that opened the palette
- Fix initSortTable: set aria-sort="ascending"/"descending"/"none" on th elements
- Fix switchTab: set aria-selected="true"/"false" on .lt-tab[data-tab] buttons
- Fix copy button timeout: guard with document.contains() before DOM mutation
- Fix combobox: add role=combobox, aria-expanded, aria-controls, role=listbox;
  toggle aria-expanded on open/close

HTML:
- Add skip nav link + id="main-content" on <main>
- Primary tab nav: add role=tablist, role=tab, aria-selected, aria-controls,
  id attrs; tab panels get role=tabpanel + aria-labelledby
- Tab bar demo: same ARIA wiring + aria-controls + role/labelledby on panels
- Sidebar filters: convert div+span to fieldset+legend for proper grouping
- Table sort headers: add aria-sort="none" (JS updates on click)
- Accordion: add aria-controls on headers, IDs on bodies
- Wizard: add aria-current="step" on active step indicator
- Table th: scope="col" on all column headers
- Row checkboxes: aria-label per ticket ID
- Worker metrics table: add <tbody>
- Progress bars: role=progressbar + aria-valuenow/min/max + aria-label
- Export + keyboard shortcuts modals: role=dialog, aria-modal, aria-labelledby

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 18:22:53 -04:00
parent 8585993602
commit fdcadad23b
3 changed files with 400 additions and 138 deletions
+187 -15
View File
@@ -175,7 +175,16 @@
--z-popover: 10012; --z-popover: 10012;
--z-tooltip: 10013; --z-tooltip: 10013;
--z-toast: 10014; --z-toast: 10014;
--z-panel: 10020; /* notification / generic dropdown panels — above everything */
--z-overlay: 9999; /* scanlines / CRT effects */ --z-overlay: 9999; /* scanlines / CRT effects */
/* --- Corner cuts (clip-path polygon notches) --- */
--corner-cut: 8px;
--corner-cut-sm: 5px;
--corner-cut-lg: 16px;
/* --- Syntax highlight --- */
--color-tok-cmt: #5c8c6a;
} }
@@ -763,6 +772,12 @@ hr {
.lt-btn:active { transform: translateY(1px); } .lt-btn:active { transform: translateY(1px); }
.lt-btn:focus-visible {
outline: 2px solid var(--accent-cyan);
outline-offset: 2px;
box-shadow: var(--box-glow-cyan);
}
.lt-btn:disabled, .lt-btn:disabled,
.lt-btn[disabled] { .lt-btn[disabled] {
opacity: 0.30; opacity: 0.30;
@@ -859,10 +874,27 @@ hr {
.lt-input:focus, .lt-input:focus,
.lt-select:focus, .lt-select:focus,
.lt-textarea:focus { .lt-textarea:focus,
.lt-input:focus-visible,
.lt-select:focus-visible,
.lt-textarea:focus-visible {
border-color: var(--accent-cyan); border-color: var(--accent-cyan);
box-shadow: var(--box-glow-cyan); box-shadow: var(--box-glow-cyan);
background: rgba(0,212,255,0.025); background: rgba(0,212,255,0.025);
outline: 2px solid var(--accent-cyan);
outline-offset: 1px;
}
.lt-input:disabled,
.lt-select:disabled,
.lt-textarea:disabled,
.lt-input[readonly],
.lt-textarea[readonly] {
opacity: 0.45;
cursor: not-allowed;
background: var(--bg-tertiary);
color: var(--text-muted);
border-color: var(--border-dim);
} }
.lt-input::placeholder, .lt-input::placeholder,
@@ -949,6 +981,47 @@ select option:checked {
text-shadow: var(--glow-cyan); text-shadow: var(--glow-cyan);
} }
.lt-checkbox:hover { border-color: var(--accent-cyan); } .lt-checkbox:hover { border-color: var(--accent-cyan); }
.lt-checkbox:focus-visible {
outline: 2px solid var(--accent-cyan);
outline-offset: 2px;
}
.lt-checkbox:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Radio (same visual language as checkbox) */
.lt-radio {
appearance: none;
width: 14px;
height: 14px;
border: 1px solid var(--accent-cyan-border);
border-radius: 50%;
background: var(--bg-terminal);
cursor: pointer;
flex-shrink: 0;
vertical-align: middle;
transition: var(--transition-fast);
position: relative;
}
.lt-radio:checked {
border-color: var(--accent-cyan);
background: var(--accent-cyan-dim);
}
.lt-radio:checked::after {
content: '';
position: absolute;
inset: 3px;
border-radius: 50%;
background: var(--accent-cyan);
box-shadow: var(--glow-cyan);
}
.lt-radio:hover { border-color: var(--accent-cyan); }
.lt-radio:focus-visible {
outline: 2px solid var(--accent-cyan);
outline-offset: 2px;
}
.lt-radio:disabled { opacity: 0.4; cursor: not-allowed; }
/* ---------------------------------------------------------------- /* ----------------------------------------------------------------
@@ -1055,7 +1128,7 @@ select option:checked {
.lt-status-closed { color: var(--status-closed); background: rgba(255,45,85,0.07); } .lt-status-closed { color: var(--status-closed); background: rgba(255,45,85,0.07); }
.lt-status-online { color: var(--status-online); background: rgba(0,255,136,0.07); text-shadow: var(--glow-green); } .lt-status-online { color: var(--status-online); background: rgba(0,255,136,0.07); text-shadow: var(--glow-green); }
.lt-status-offline { color: var(--status-offline); background: rgba(255,45,85,0.07); } .lt-status-offline { color: var(--status-offline); background: rgba(255,45,85,0.07); }
.lt-status-running { color: var(--status-running); background: rgba(255,179,0,0.07); animation: pulse-amber 2s infinite; } .lt-status-running { color: var(--status-running); background: rgba(255,179,0,0.07); animation: pulse-amber 2s infinite; will-change: color; }
.lt-status-completed { color: var(--status-completed); background: rgba(0,212,255,0.07); } .lt-status-completed { color: var(--status-completed); background: rgba(0,212,255,0.07); }
.lt-status-failed { color: var(--status-failed); background: rgba(255,45,85,0.07); } .lt-status-failed { color: var(--status-failed); background: rgba(255,45,85,0.07); }
@@ -1107,6 +1180,17 @@ select option:checked {
.lt-badge-amber { color: var(--accent-amber); } .lt-badge-amber { color: var(--accent-amber); }
.lt-badge-red { color: var(--accent-red); } .lt-badge-red { color: var(--accent-red); }
/* Status + priority badge variants (dark-mode base) */
.lt-badge-open { color: var(--accent-green); background: rgba(0,255,136,0.08); border-color: rgba(0,255,136,0.35); text-shadow: var(--glow-green); }
.lt-badge-closed { color: var(--text-muted); background: rgba(74,90,112,0.10); border-color: rgba(74,90,112,0.35); }
.lt-badge-in-progress,
.lt-badge-progress { color: var(--accent-amber); background: rgba(255,179,0,0.08); border-color: rgba(255,179,0,0.35); }
.lt-badge-pending { color: var(--accent-purple); background: rgba(191,95,255,0.08); border-color: rgba(191,95,255,0.35); }
.lt-badge-p1 { color: var(--accent-red); background: rgba(255,45,85,0.09); border-color: rgba(255,45,85,0.40); text-shadow: var(--glow-red); }
.lt-badge-p2 { color: var(--accent-orange); background: rgba(255,107,0,0.09); border-color: rgba(255,107,0,0.38); }
.lt-badge-p3 { color: var(--accent-cyan); background: rgba(0,212,255,0.07); border-color: rgba(0,212,255,0.30); }
.lt-badge-p4 { color: var(--accent-green); background: rgba(0,255,136,0.07); border-color: rgba(0,255,136,0.30); }
/* Status dots */ /* Status dots */
.lt-dot { .lt-dot {
display: inline-block; display: inline-block;
@@ -1115,9 +1199,9 @@ select option:checked {
border-radius: 50%; border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
} }
.lt-dot-up { background: var(--accent-green); box-shadow: 0 0 6px var(--accent-green), 0 0 14px var(--accent-green); animation: pulse-green 2.2s ease-in-out infinite; } .lt-dot-up { background: var(--accent-green); box-shadow: 0 0 6px var(--accent-green), 0 0 14px var(--accent-green); animation: pulse-green 2.2s ease-in-out infinite; will-change: box-shadow; }
.lt-dot-down { background: var(--accent-red); box-shadow: 0 0 6px var(--accent-red), 0 0 12px var(--accent-red); } .lt-dot-down { background: var(--accent-red); box-shadow: 0 0 6px var(--accent-red), 0 0 12px var(--accent-red); }
.lt-dot-warn { background: var(--accent-amber); box-shadow: 0 0 6px var(--accent-amber), 0 0 12px var(--accent-amber); animation: pulse-amber 2.2s ease-in-out infinite; } .lt-dot-warn { background: var(--accent-amber); box-shadow: 0 0 6px var(--accent-amber), 0 0 12px var(--accent-amber); animation: pulse-amber 2.2s ease-in-out infinite; will-change: box-shadow; }
.lt-dot-idle { background: var(--text-muted); box-shadow: none; } .lt-dot-idle { background: var(--text-muted); box-shadow: none; }
@@ -1194,6 +1278,8 @@ select option:checked {
transition: var(--transition-fast); transition: var(--transition-fast);
} }
.lt-modal-close:hover { color: var(--accent-red); text-shadow: var(--glow-red); } .lt-modal-close:hover { color: var(--accent-red); text-shadow: var(--glow-red); }
.lt-modal-close:active { color: var(--accent-red); opacity: 0.7; }
.lt-modal-close:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; border-radius: 2px; }
.lt-modal-body { .lt-modal-body {
padding: var(--space-lg); padding: var(--space-lg);
@@ -1292,6 +1378,10 @@ select option:checked {
border-color: var(--accent-orange-border); border-color: var(--accent-orange-border);
border-bottom-color: var(--bg-card); border-bottom-color: var(--bg-card);
} }
.lt-tab:focus-visible {
outline: 2px solid var(--accent-orange);
outline-offset: -2px;
}
.lt-tab-panel { display: none; } .lt-tab-panel { display: none; }
.lt-tab-panel.active { display: block; } .lt-tab-panel.active { display: block; }
@@ -1339,8 +1429,10 @@ select option:checked {
.lt-filter-group { .lt-filter-group {
margin-bottom: var(--space-md); margin-bottom: var(--space-md);
padding-bottom: var(--space-md); padding: 0 0 var(--space-md) 0;
border: none;
border-bottom: 1px solid var(--border-color-dim); border-bottom: 1px solid var(--border-color-dim);
min-width: 0; /* fieldset UA reset */
} }
.lt-filter-group:last-child { border-bottom: none; } .lt-filter-group:last-child { border-bottom: none; }
@@ -2195,7 +2287,35 @@ select option:checked {
.lt-text-upper { text-transform: uppercase; letter-spacing: 0.1em; } .lt-text-upper { text-transform: uppercase; letter-spacing: 0.1em; }
.lt-hidden { display: none !important; } .lt-hidden { display: none !important; }
.lt-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
/* Skip navigation link — visible only on focus */
.lt-skip-link {
position: absolute;
top: -100%;
left: var(--space-sm);
padding: var(--space-xs) var(--space-md);
background: var(--bg-terminal);
color: var(--accent-cyan);
border: 1px solid var(--accent-cyan);
font-family: var(--font-mono);
font-size: 0.8rem;
text-decoration: none;
text-transform: uppercase;
letter-spacing: 0.05em;
z-index: var(--z-toast);
transition: top 0.1s;
}
.lt-skip-link:focus { top: var(--space-sm); }
.lt-sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip-path: inset(50%); /* replaces deprecated clip: rect(0,0,0,0) */
white-space: nowrap;
border: 0;
}
/* Cursor blink */ /* Cursor blink */
.lt-cursor::after { .lt-cursor::after {
@@ -2206,6 +2326,9 @@ select option:checked {
font-size: 0.85em; font-size: 0.85em;
margin-left: 1px; margin-left: 1px;
} }
@media (prefers-reduced-motion: reduce) {
.lt-cursor::after { animation: none; }
}
/* Glitch text effect */ /* Glitch text effect */
.lt-glitch { position: relative; } .lt-glitch { position: relative; }
@@ -2221,11 +2344,13 @@ select option:checked {
color: var(--accent-cyan); color: var(--accent-cyan);
opacity: 0.65; opacity: 0.65;
animation: glitch-1 4s infinite; animation: glitch-1 4s infinite;
will-change: clip-path, transform;
} }
.lt-glitch::after { .lt-glitch::after {
color: var(--accent-orange); color: var(--accent-orange);
opacity: 0.65; opacity: 0.65;
animation: glitch-2 4s 0.12s infinite; animation: glitch-2 4s 0.12s infinite;
will-change: clip-path, transform;
} }
@@ -2459,6 +2584,7 @@ select option:checked {
text-align: left; text-align: left;
} }
.lt-accordion-header:hover { background: var(--bg-tertiary); color: var(--accent-cyan); } .lt-accordion-header:hover { background: var(--bg-tertiary); color: var(--accent-cyan); }
.lt-accordion-header:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: -2px; }
.lt-accordion-header[aria-expanded="true"] { color: var(--accent-orange); } .lt-accordion-header[aria-expanded="true"] { color: var(--accent-orange); }
.lt-accordion-icon { .lt-accordion-icon {
width: 14px; width: 14px;
@@ -2573,9 +2699,17 @@ input[type="range"].lt-range {
height: 4px; height: 4px;
background: var(--bg-tertiary); background: var(--bg-tertiary);
border: 1px solid var(--border-dim); border: 1px solid var(--border-dim);
outline: none; outline: none; /* reset; :focus-visible below provides keyboard ring */
cursor: pointer; cursor: pointer;
} }
input[type="range"].lt-range:focus-visible {
outline: 2px solid var(--accent-orange);
outline-offset: 4px;
}
input[type="range"].lt-range:disabled {
opacity: 0.4;
cursor: not-allowed;
}
input[type="range"].lt-range::-webkit-slider-thumb { input[type="range"].lt-range::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
width: 14px; height: 14px; width: 14px; height: 14px;
@@ -2586,6 +2720,7 @@ input[type="range"].lt-range::-webkit-slider-thumb {
transition: transform 0.15s; transition: transform 0.15s;
} }
input[type="range"].lt-range::-webkit-slider-thumb:hover { transform: scale(1.3); } input[type="range"].lt-range::-webkit-slider-thumb:hover { transform: scale(1.3); }
input[type="range"].lt-range:focus-visible::-webkit-slider-thumb { transform: scale(1.3); box-shadow: 0 0 0 3px var(--bg-primary), 0 0 0 5px var(--accent-cyan); }
input[type="range"].lt-range::-moz-range-thumb { input[type="range"].lt-range::-moz-range-thumb {
width: 14px; height: 14px; width: 14px; height: 14px;
background: var(--accent-orange); background: var(--accent-orange);
@@ -2638,6 +2773,8 @@ input[type="range"].lt-range::-moz-range-thumb {
.lt-file-item-name { color: var(--text-secondary); } .lt-file-item-name { color: var(--text-secondary); }
.lt-file-item-size { color: var(--text-dim); } .lt-file-item-size { color: var(--text-dim); }
.lt-file-item-remove { background: none; border: none; color: var(--accent-red); cursor: pointer; padding: 0; font-size: 0.9rem; } .lt-file-item-remove { background: none; border: none; color: var(--accent-red); cursor: pointer; padding: 0; font-size: 0.9rem; }
.lt-file-item-remove:hover { opacity: 0.75; text-shadow: var(--glow-red); }
.lt-file-item-remove:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; border-radius: 2px; }
/* ---------------------------------------------------------------- /* ----------------------------------------------------------------
@@ -2681,6 +2818,7 @@ input[type="range"].lt-range::-moz-range-thumb {
font-size: 0.9rem; font-size: 0.9rem;
caret-color: var(--accent-orange); caret-color: var(--accent-orange);
} }
.lt-cmd-input-wrap:focus-within { border-bottom-color: var(--accent-cyan); }
.lt-cmd-results { max-height: 320px; overflow-y: auto; } .lt-cmd-results { max-height: 320px; overflow-y: auto; }
.lt-cmd-group-label { .lt-cmd-group-label {
padding: var(--space-xs) var(--space-md); padding: var(--space-xs) var(--space-md);
@@ -2788,7 +2926,7 @@ input[type="range"].lt-range::-moz-range-thumb {
.tok-kw { color: var(--accent-cyan); } .tok-kw { color: var(--accent-cyan); }
.tok-str { color: var(--accent-green); } .tok-str { color: var(--accent-green); }
.tok-num { color: var(--accent-orange); } .tok-num { color: var(--accent-orange); }
.tok-cmt { color: #5c8c6a; font-style: italic; } .tok-cmt { color: var(--color-tok-cmt); font-style: italic; }
.tok-fn { color: var(--accent-purple); } .tok-fn { color: var(--accent-purple); }
@@ -2827,6 +2965,7 @@ input[type="range"].lt-range::-moz-range-thumb {
line-height: 1; line-height: 1;
} }
.lt-tag-remove:hover { opacity: 1; } .lt-tag-remove:hover { opacity: 1; }
.lt-tag-remove:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 1px; border-radius: 2px; opacity: 1; }
/* ---------------------------------------------------------------- /* ----------------------------------------------------------------
@@ -3113,7 +3252,8 @@ input[type="range"].lt-range::-moz-range-thumb {
.lt-gauge svg { overflow: visible; } .lt-gauge svg { overflow: visible; }
.lt-gauge-track { fill: none; stroke: var(--bg-tertiary); stroke-width: 8; } .lt-gauge-track { fill: none; stroke: var(--bg-tertiary); stroke-width: 8; }
.lt-gauge-fill { fill: none; stroke: var(--accent-orange); stroke-width: 8; stroke-linecap: butt; .lt-gauge-fill { fill: none; stroke: var(--accent-orange); stroke-width: 8; stroke-linecap: butt;
transition: stroke-dashoffset 0.6s cubic-bezier(0.4,0,0.2,1); } transition: stroke-dashoffset 0.6s cubic-bezier(0.4,0,0.2,1);
will-change: stroke-dashoffset; }
.lt-gauge-label { .lt-gauge-label {
position: absolute; position: absolute;
bottom: 0; left: 50%; bottom: 0; left: 50%;
@@ -3138,6 +3278,7 @@ input[type="range"].lt-range::-moz-range-thumb {
border-top-color: var(--accent-orange); border-top-color: var(--accent-orange);
border-radius: 50%; border-radius: 50%;
animation: lt-spin 0.7s linear infinite; animation: lt-spin 0.7s linear infinite;
will-change: transform;
} }
.lt-spinner--cyan { border-top-color: var(--accent-cyan); } .lt-spinner--cyan { border-top-color: var(--accent-cyan); }
.lt-spinner--green { border-top-color: var(--accent-green); } .lt-spinner--green { border-top-color: var(--accent-green); }
@@ -3247,7 +3388,6 @@ input[type="range"].lt-range::-moz-range-thumb {
.lt-border-red { border-color: var(--accent-red) !important; } .lt-border-red { border-color: var(--accent-red) !important; }
/* Display */ /* Display */
.lt-hidden { display: none !important; }
.lt-visible { display: block !important; } .lt-visible { display: block !important; }
.lt-flex { display: flex; } .lt-flex { display: flex; }
.lt-grid { display: grid; } .lt-grid { display: grid; }
@@ -3404,6 +3544,9 @@ html[data-theme="light"] {
--box-glow-red: 0 0 0 2px rgba(181,0,31,0.22), 0 2px 8px rgba(181,0,31,0.12); --box-glow-red: 0 0 0 2px rgba(181,0,31,0.22), 0 2px 8px rgba(181,0,31,0.12);
--box-glow-amber: 0 0 0 2px rgba(138,90,0,0.22), 0 2px 8px rgba(138,90,0,0.12); --box-glow-amber: 0 0 0 2px rgba(138,90,0,0.22), 0 2px 8px rgba(138,90,0,0.12);
/* — Syntax highlight — */
--color-tok-cmt: #2e6540;
color-scheme: light; color-scheme: light;
} }
@@ -3506,7 +3649,10 @@ html[data-theme="light"] .lt-table-wrap { border-color: var(--border-color);
html[data-theme="light"] .lt-badge { background: var(--bg-tertiary); color: var(--text-secondary); } html[data-theme="light"] .lt-badge { background: var(--bg-tertiary); color: var(--text-secondary); }
html[data-theme="light"] .lt-badge-open { background: rgba(0,109,53,0.12); color: #006d35; } html[data-theme="light"] .lt-badge-open { background: rgba(0,109,53,0.12); color: #006d35; }
html[data-theme="light"] .lt-badge-closed { background: rgba(90,110,150,0.12); color: #5a6e8c; } html[data-theme="light"] .lt-badge-closed { background: rgba(90,110,150,0.12); color: #5a6e8c; }
html[data-theme="light"] .lt-badge-p1 { background: rgba(181,0,31,0.12); color: #b5001f; } html[data-theme="light"] .lt-badge-in-progress,
html[data-theme="light"] .lt-badge-progress { background: rgba(138,90,0,0.12); color: #8a5a00; text-shadow: none; }
html[data-theme="light"] .lt-badge-pending { background: rgba(107,47,184,0.10); color: #7c22cc; }
html[data-theme="light"] .lt-badge-p1 { background: rgba(181,0,31,0.12); color: #b5001f; text-shadow: none; }
html[data-theme="light"] .lt-badge-p2 { background: rgba(196,78,0,0.12); color: #c44e00; } html[data-theme="light"] .lt-badge-p2 { background: rgba(196,78,0,0.12); color: #c44e00; }
html[data-theme="light"] .lt-badge-p3 { background: rgba(138,90,0,0.12); color: #8a5a00; } html[data-theme="light"] .lt-badge-p3 { background: rgba(138,90,0,0.12); color: #8a5a00; }
html[data-theme="light"] .lt-badge-p4 { background: rgba(50,80,130,0.10); color: #2d3d56; } html[data-theme="light"] .lt-badge-p4 { background: rgba(50,80,130,0.10); color: #2d3d56; }
@@ -3614,8 +3760,8 @@ html[data-theme="light"] .lt-divider-label { color: var(--text-dim); }
html[data-theme="light"] .lt-tag { background: var(--bg-tertiary); border-color: var(--border-color); color: var(--text-secondary); } html[data-theme="light"] .lt-tag { background: var(--bg-tertiary); border-color: var(--border-color); color: var(--text-secondary); }
/* — Tooltips — */ /* — Tooltips — */
html[data-theme="light"] [data-tooltip]::before { background: #1a2035; color: #fff; } html[data-theme="light"] [data-tooltip]::before { background: var(--bg-terminal); color: var(--text-primary); }
html[data-theme="light"] [data-tooltip]::after { border-top-color: #1a2035; } html[data-theme="light"] [data-tooltip]::after { border-top-color: var(--bg-terminal); }
/* — Pagination — */ /* — Pagination — */
html[data-theme="light"] .lt-page-btn { background: var(--bg-card); border-color: var(--border-color); color: var(--text-secondary); } html[data-theme="light"] .lt-page-btn { background: var(--bg-card); border-color: var(--border-color); color: var(--text-secondary); }
@@ -3766,6 +3912,7 @@ html[data-theme="light"] ::-webkit-scrollbar-thumb:hover { background: var(--acc
animation: lt-shimmer 1.6s ease-in-out infinite; animation: lt-shimmer 1.6s ease-in-out infinite;
border-radius: 2px; border-radius: 2px;
display: block; display: block;
will-change: opacity;
} }
@keyframes lt-shimmer { @keyframes lt-shimmer {
0% { background-position: 200% 0; } 0% { background-position: 200% 0; }
@@ -3942,6 +4089,7 @@ html[data-theme="light"] ::-webkit-scrollbar-thumb:hover { background: var(--acc
transition: var(--transition-fast); transition: var(--transition-fast);
} }
.lt-drawer-right-close:hover { color: var(--accent-red); border-color: var(--accent-red); } .lt-drawer-right-close:hover { color: var(--accent-red); border-color: var(--accent-red); }
.lt-drawer-right-close:active { color: var(--accent-red); opacity: 0.7; }
.lt-drawer-right-close:focus-visible { outline: 1px solid var(--accent-cyan); outline-offset: 2px; } .lt-drawer-right-close:focus-visible { outline: 1px solid var(--accent-cyan); outline-offset: 2px; }
.lt-drawer-right-body { .lt-drawer-right-body {
flex: 1; flex: 1;
@@ -4056,6 +4204,7 @@ html[data-theme="light"] ::-webkit-scrollbar-thumb:hover { background: var(--acc
display: flex; align-items: center; display: flex; align-items: center;
} }
.lt-combobox-tag-remove:hover { color: var(--accent-red); } .lt-combobox-tag-remove:hover { color: var(--accent-red); }
.lt-combobox-tag-remove:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 1px; border-radius: 2px; }
/* Dropdown list */ /* Dropdown list */
.lt-combobox-dropdown { .lt-combobox-dropdown {
position: absolute; position: absolute;
@@ -4908,7 +5057,7 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 0 100%); clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 0 100%);
z-index: 10020; z-index: var(--z-panel);
box-shadow: 0 8px 32px rgba(0,0,0,0.5); box-shadow: 0 8px 32px rgba(0,0,0,0.5);
transform-origin: top right; transform-origin: top right;
transform: scale(0.95); transform: scale(0.95);
@@ -4947,6 +5096,8 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
.lt-notif-panel-clear:hover { text-decoration: underline; } .lt-notif-panel-clear:hover { text-decoration: underline; }
.lt-notif-panel-clear:active { opacity: 0.7; }
.lt-notif-panel-clear:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; border-radius: 2px; }
.lt-notif-panel-list { max-height: min(280px, calc(80vh - 110px)); overflow-y: auto; } .lt-notif-panel-list { max-height: min(280px, calc(80vh - 110px)); overflow-y: auto; }
@@ -5015,7 +5166,7 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 0 100%); clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 0 100%);
z-index: 10020; z-index: var(--z-panel);
box-shadow: 0 8px 24px rgba(0,0,0,0.4); box-shadow: 0 8px 24px rgba(0,0,0,0.4);
transform-origin: top left; transform-origin: top left;
transform: scale(0.95); transform: scale(0.95);
@@ -5250,3 +5401,24 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
.lt-stats-grid { grid-template-columns: repeat(8, 1fr); max-width: 100%; } .lt-stats-grid { grid-template-columns: repeat(8, 1fr); max-width: 100%; }
.lt-modal { max-width: 800px; } .lt-modal { max-width: 800px; }
} }
/* ----------------------------------------------------------------
79. FOOTER
---------------------------------------------------------------- */
.lt-footer {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--space-sm);
padding: var(--space-md) var(--space-lg);
border-top: 1px solid var(--border-dim);
margin-top: auto;
background: var(--bg-secondary);
color: var(--text-muted);
font-size: 0.7rem;
font-family: var(--font-mono);
}
@media (max-width: 479px) {
.lt-footer { flex-direction: column; align-items: flex-start; gap: 0.25rem; }
}
+100 -79
View File
@@ -1,6 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<!-- <!--
LOTUSGUILD TERMINAL DESIGN SYSTEM v2.0 — base.html LOTUSGUILD TERMINAL DESIGN SYSTEM v1.2 — base.html
Reference template showing every component and layout pattern. Reference template showing every component and layout pattern.
This file is a STATIC DEMO. Framework-specific wiring is in: This file is a STATIC DEMO. Framework-specific wiring is in:
@@ -21,6 +21,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>MY APP — LotusGuild</title> <title>MY APP — LotusGuild</title>
<meta name="description" content="LotusGuild infrastructure application"> <meta name="description" content="LotusGuild infrastructure application">
<meta name="robots" content="noindex, nofollow">
<!-- ========================================================= <!-- =========================================================
Security headers are set server-side. CSP nonce is injected Security headers are set server-side. CSP nonce is injected
@@ -44,6 +45,8 @@
</head> </head>
<body> <body>
<a class="lt-skip-link" href="#main-content">Skip to main content</a>
<!-- =========================================================== <!-- ===========================================================
BOOT SEQUENCE OVERLAY BOOT SEQUENCE OVERLAY
Displays once per session. Remove if not desired. Displays once per session. Remove if not desired.
@@ -59,7 +62,7 @@
<!-- =========================================================== <!-- ===========================================================
MOBILE NAV DRAWER (hidden on desktop, slides in on mobile) MOBILE NAV DRAWER (hidden on desktop, slides in on mobile)
=========================================================== --> =========================================================== -->
<div id="lt-nav-drawer" class="lt-nav-drawer" aria-hidden="true" role="dialog" aria-label="Navigation menu"> <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"> <div class="lt-nav-drawer-header">
<span class="lt-brand-title">MY APP</span> <span class="lt-brand-title">MY APP</span>
<button class="lt-nav-drawer-close" id="lt-nav-drawer-close" aria-label="Close menu"></button> <button class="lt-nav-drawer-close" id="lt-nav-drawer-close" aria-label="Close menu"></button>
@@ -168,7 +171,7 @@
</header> </header>
<!-- Right-side detail drawer --> <!-- Right-side detail drawer -->
<div id="lt-detail-drawer" class="lt-drawer-right" aria-hidden="true" role="dialog" aria-label="Detail panel" data-overlay="lt-detail-overlay"> <div id="lt-detail-drawer" class="lt-drawer-right" aria-hidden="true" role="dialog" aria-modal="true" aria-label="Detail panel" data-overlay="lt-detail-overlay">
<div class="lt-drawer-right-header"> <div class="lt-drawer-right-header">
<span class="lt-drawer-right-title">// TICKET DETAIL</span> <span class="lt-drawer-right-title">// TICKET DETAIL</span>
<button class="lt-drawer-right-close" data-drawer-close aria-label="Close detail panel"></button> <button class="lt-drawer-right-close" data-drawer-close aria-label="Close detail panel"></button>
@@ -224,7 +227,7 @@
<div class="lt-form-group" style="margin-bottom:0.5rem"> <div class="lt-form-group" style="margin-bottom:0.5rem">
<textarea id="td-comment" class="lt-input lt-textarea" rows="2" placeholder="Leave a comment…" style="resize:vertical"></textarea> <textarea id="td-comment" class="lt-input lt-textarea" rows="2" placeholder="Leave a comment…" style="resize:vertical"></textarea>
</div> </div>
<button class="lt-btn lt-btn-sm" onclick=" <button type="button" class="lt-btn lt-btn-sm" onclick="
const c=document.getElementById('td-comment'); const c=document.getElementById('td-comment');
if(c.value.trim()){lt.toast.success('Comment posted');c.value='';} if(c.value.trim()){lt.toast.success('Comment posted');c.value='';}
else lt.toast.warning('Comment is empty'); else lt.toast.warning('Comment is empty');
@@ -248,8 +251,8 @@
</div> </div>
</div> </div>
<div class="lt-drawer-right-footer"> <div class="lt-drawer-right-footer">
<button class="lt-btn lt-btn-sm" onclick="lt.rightDrawer.close('lt-detail-drawer')">Cancel</button> <button type="button" class="lt-btn lt-btn-sm" onclick="lt.rightDrawer.close('lt-detail-drawer')">Cancel</button>
<button class="lt-btn lt-btn-primary lt-btn-sm" onclick="lt.toast.success('Ticket #123456789 updated')">Save Changes</button> <button type="button" class="lt-btn lt-btn-primary lt-btn-sm" onclick="lt.toast.success('Ticket #123456789 updated')">Save Changes</button>
</div> </div>
</div> </div>
<div id="lt-detail-overlay" class="lt-drawer-right-overlay"></div> <div id="lt-detail-overlay" class="lt-drawer-right-overlay"></div>
@@ -257,7 +260,7 @@
<!-- =========================================================== <!-- ===========================================================
MAIN CONTENT AREA MAIN CONTENT AREA
=========================================================== --> =========================================================== -->
<main class="lt-main lt-container"> <main class="lt-main lt-container" id="main-content">
<!-- Page title bar --> <!-- Page title bar -->
<div class="lt-page-header"> <div class="lt-page-header">
@@ -306,10 +309,10 @@
<!-- ========================================================== <!-- ==========================================================
TAB NAVIGATION TAB NAVIGATION
========================================================== --> ========================================================== -->
<div class="lt-tabs"> <div class="lt-tabs" role="tablist" aria-label="Main views">
<button class="lt-tab active" data-tab="tab-table">Table View</button> <button class="lt-tab active" role="tab" data-tab="tab-table" aria-selected="true" aria-controls="tab-table" id="tab-btn-table">Table View</button>
<button class="lt-tab" data-tab="tab-kanban">Kanban</button> <button class="lt-tab" role="tab" data-tab="tab-kanban" aria-selected="false" aria-controls="tab-kanban" id="tab-btn-kanban">Kanban</button>
<button class="lt-tab" data-tab="tab-workers">Workers</button> <button class="lt-tab" role="tab" data-tab="tab-workers" aria-selected="false" aria-controls="tab-workers" id="tab-btn-workers">Workers</button>
</div> </div>
<!-- ========================================================== <!-- ==========================================================
@@ -326,8 +329,8 @@
</div> </div>
<div class="lt-sidebar-body"> <div class="lt-sidebar-body">
<div class="lt-filter-group"> <fieldset class="lt-filter-group">
<span class="lt-filter-label">Status</span> <legend class="lt-filter-label">Status</legend>
<label class="lt-filter-option"> <label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" checked> Open <input type="checkbox" class="lt-checkbox" checked> Open
</label> </label>
@@ -340,10 +343,10 @@
<label class="lt-filter-option"> <label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox"> Closed <input type="checkbox" class="lt-checkbox"> Closed
</label> </label>
</div> </fieldset>
<div class="lt-filter-group"> <fieldset class="lt-filter-group">
<span class="lt-filter-label">Priority</span> <legend class="lt-filter-label">Priority</legend>
<label class="lt-filter-option"> <label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox"> P1 Critical <input type="checkbox" class="lt-checkbox"> P1 Critical
</label> </label>
@@ -353,7 +356,7 @@
<label class="lt-filter-option"> <label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox"> P3 Medium <input type="checkbox" class="lt-checkbox"> P3 Medium
</label> </label>
</div> </fieldset>
<div class="lt-filter-group"> <div class="lt-filter-group">
<span class="lt-filter-label">Assigned To</span> <span class="lt-filter-label">Assigned To</span>
@@ -381,7 +384,7 @@
<div class="lt-toolbar-left"> <div class="lt-toolbar-left">
<div class="lt-search"> <div class="lt-search">
<input type="search" class="lt-input lt-search-input" id="ticket-search" <input type="search" class="lt-input lt-search-input" id="ticket-search"
placeholder="Search tickets..." aria-label="Search"> placeholder="Search tickets..." aria-label="Search" autocomplete="off">
</div> </div>
<!-- Advanced filter dropdown --> <!-- Advanced filter dropdown -->
<div class="lt-dropdown-wrap" id="adv-filter-wrap"> <div class="lt-dropdown-wrap" id="adv-filter-wrap">
@@ -419,8 +422,8 @@
</select> </select>
</div> </div>
<div style="display:flex;gap:0.5rem;margin-top:0.25rem"> <div style="display:flex;gap:0.5rem;margin-top:0.25rem">
<button class="lt-btn lt-btn-sm lt-btn-primary" style="flex:1" onclick="lt.toast.info('Filters applied');document.getElementById('adv-filter-panel').setAttribute('aria-hidden','true');document.getElementById('adv-filter-btn').setAttribute('aria-expanded','false')">Apply</button> <button type="button" class="lt-btn lt-btn-sm lt-btn-primary" style="flex:1" onclick="lt.toast.info('Filters applied');document.getElementById('adv-filter-panel').setAttribute('aria-hidden','true');document.getElementById('adv-filter-btn').setAttribute('aria-expanded','false')">Apply</button>
<button class="lt-btn lt-btn-sm lt-btn-ghost" onclick="lt.toast.info('Filters cleared')">Reset</button> <button type="button" class="lt-btn lt-btn-sm lt-btn-ghost" onclick="lt.toast.info('Filters cleared')">Reset</button>
</div> </div>
</div> </div>
</div> </div>
@@ -432,11 +435,11 @@
<div class="lt-dropdown-wrap" id="bulk-action-wrap"> <div class="lt-dropdown-wrap" id="bulk-action-wrap">
<button class="lt-btn lt-btn-sm lt-btn-ghost lt-dropdown-trigger" id="bulk-action-btn" aria-expanded="false" aria-haspopup="true">Bulk Actions ▾</button> <button class="lt-btn lt-btn-sm lt-btn-ghost lt-dropdown-trigger" id="bulk-action-btn" aria-expanded="false" aria-haspopup="true">Bulk Actions ▾</button>
<div class="lt-dropdown-panel lt-dropdown-panel--right" id="bulk-action-panel" aria-hidden="true"> <div class="lt-dropdown-panel lt-dropdown-panel--right" id="bulk-action-panel" aria-hidden="true">
<button class="lt-dropdown-item" onclick="lt.toast.success('Closed selected tickets');this.closest('.lt-dropdown-panel').setAttribute('aria-hidden','true')">✓ Close Selected</button> <button type="button" class="lt-dropdown-item" onclick="lt.toast.success('Closed selected tickets');this.closest('.lt-dropdown-panel').setAttribute('aria-hidden','true')">✓ Close Selected</button>
<button class="lt-dropdown-item" onclick="lt.toast.info('Reassign dialog…');this.closest('.lt-dropdown-panel').setAttribute('aria-hidden','true')">↩ Reassign…</button> <button type="button" class="lt-dropdown-item" onclick="lt.toast.info('Reassign dialog…');this.closest('.lt-dropdown-panel').setAttribute('aria-hidden','true')">↩ Reassign…</button>
<button class="lt-dropdown-item" onclick="lt.toast.info('Exporting selected…');this.closest('.lt-dropdown-panel').setAttribute('aria-hidden','true')">⤓ Export Selected</button> <button type="button" class="lt-dropdown-item" onclick="lt.toast.info('Exporting selected…');this.closest('.lt-dropdown-panel').setAttribute('aria-hidden','true')">⤓ Export Selected</button>
<div class="lt-dropdown-divider"></div> <div class="lt-dropdown-divider"></div>
<button class="lt-dropdown-item lt-dropdown-item--danger" onclick="lt.toast.error('Deleted selected');this.closest('.lt-dropdown-panel').setAttribute('aria-hidden','true')">🗑 Delete Selected</button> <button type="button" class="lt-dropdown-item lt-dropdown-item--danger" onclick="lt.toast.error('Deleted selected');this.closest('.lt-dropdown-panel').setAttribute('aria-hidden','true')">🗑 Delete Selected</button>
</div> </div>
</div> </div>
</div> </div>
@@ -445,7 +448,7 @@
<!-- ================================================== <!-- ==================================================
TAB PANEL: TABLE VIEW TAB PANEL: TABLE VIEW
================================================== --> ================================================== -->
<div id="tab-table" class="lt-tab-panel active"> <div id="tab-table" class="lt-tab-panel active" role="tabpanel" aria-labelledby="tab-btn-table">
<!-- Outer ASCII frame wrapping the table --> <!-- Outer ASCII frame wrapping the table -->
<div class="lt-frame"> <div class="lt-frame">
@@ -459,21 +462,21 @@
<caption class="lt-sr-only">Ticket queue — sorted by priority</caption> <caption class="lt-sr-only">Ticket queue — sorted by priority</caption>
<thead> <thead>
<tr> <tr>
<th><input type="checkbox" class="lt-checkbox" aria-label="Select all"></th> <th scope="col"><input type="checkbox" class="lt-checkbox" aria-label="Select all"></th>
<th data-sort-key="id">ID</th> <th scope="col" data-sort-key="id" aria-sort="none">ID</th>
<th data-sort-key="priority">Priority</th> <th scope="col" data-sort-key="priority" aria-sort="none">Priority</th>
<th data-sort-key="title">Title</th> <th scope="col" data-sort-key="title" aria-sort="none">Title</th>
<th data-sort-key="status">Status</th> <th scope="col" data-sort-key="status" aria-sort="none">Status</th>
<th data-sort-key="assignee">Assignee</th> <th scope="col" data-sort-key="assignee" aria-sort="none">Assignee</th>
<th data-sort-key="created">Created</th> <th scope="col" data-sort-key="created" aria-sort="none">Created</th>
<th>Actions</th> <th scope="col">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<!-- P1 Critical row --> <!-- P1 Critical row -->
<tr class="lt-row-p1 lt-row-critical"> <tr class="lt-row-p1 lt-row-critical">
<td data-label="Select"><input type="checkbox" class="lt-checkbox"></td> <td data-label="Select"><input type="checkbox" class="lt-checkbox" aria-label="Select ticket #123456789"></td>
<td data-label="ID"><a href="/ticket/123456789">#123456789</a></td> <td data-label="ID"><a href="/ticket/123456789">#123456789</a></td>
<td data-label="Priority"><span class="lt-p1">P1 Critical</span></td> <td data-label="Priority"><span class="lt-p1">P1 Critical</span></td>
<td data-label="Title">Storage array link-down on compute-storage-01</td> <td data-label="Title">Storage array link-down on compute-storage-01</td>
@@ -490,7 +493,7 @@
<!-- P2 High row --> <!-- P2 High row -->
<tr class="lt-row-p2 lt-row-warning"> <tr class="lt-row-p2 lt-row-warning">
<td data-label="Select"><input type="checkbox" class="lt-checkbox"></td> <td data-label="Select"><input type="checkbox" class="lt-checkbox" aria-label="Select ticket #987654321"></td>
<td data-label="ID"><a href="/ticket/987654321">#987654321</a></td> <td data-label="ID"><a href="/ticket/987654321">#987654321</a></td>
<td data-label="Priority"><span class="lt-p2">P2 High</span></td> <td data-label="Priority"><span class="lt-p2">P2 High</span></td>
<td data-label="Title">Switch port flapping on USW-Pro-24</td> <td data-label="Title">Switch port flapping on USW-Pro-24</td>
@@ -506,7 +509,7 @@
<!-- P3 Medium row --> <!-- P3 Medium row -->
<tr class="lt-row-p3"> <tr class="lt-row-p3">
<td data-label="Select"><input type="checkbox" class="lt-checkbox"></td> <td data-label="Select"><input type="checkbox" class="lt-checkbox" aria-label="Select ticket #111222333"></td>
<td data-label="ID"><a href="/ticket/111222333">#111222333</a></td> <td data-label="ID"><a href="/ticket/111222333">#111222333</a></td>
<td data-label="Priority"><span class="lt-p3">P3 Med</span></td> <td data-label="Priority"><span class="lt-p3">P3 Med</span></td>
<td data-label="Title">Scheduled maintenance: replace SFP+ on large1</td> <td data-label="Title">Scheduled maintenance: replace SFP+ on large1</td>
@@ -522,7 +525,7 @@
<!-- P4 closed --> <!-- P4 closed -->
<tr class="lt-row-p4"> <tr class="lt-row-p4">
<td data-label="Select"><input type="checkbox" class="lt-checkbox"></td> <td data-label="Select"><input type="checkbox" class="lt-checkbox" aria-label="Select ticket #444555666"></td>
<td data-label="ID"><a href="/ticket/444555666">#444555666</a></td> <td data-label="ID"><a href="/ticket/444555666">#444555666</a></td>
<td data-label="Priority"><span class="lt-p4">P4 Low</span></td> <td data-label="Priority"><span class="lt-p4">P4 Low</span></td>
<td data-label="Title">Update SSL cert on wiki.lotusguild.org</td> <td data-label="Title">Update SSL cert on wiki.lotusguild.org</td>
@@ -546,7 +549,7 @@
<!-- ================================================== <!-- ==================================================
TAB PANEL: KANBAN TAB PANEL: KANBAN
================================================== --> ================================================== -->
<div id="tab-kanban" class="lt-tab-panel"> <div id="tab-kanban" class="lt-tab-panel" role="tabpanel" aria-labelledby="tab-btn-kanban">
<div class="lt-grid-4"> <div class="lt-grid-4">
<!-- Kanban column: Open --> <!-- Kanban column: Open -->
@@ -634,7 +637,7 @@
<!-- ================================================== <!-- ==================================================
TAB PANEL: WORKERS TAB PANEL: WORKERS
================================================== --> ================================================== -->
<div id="tab-workers" class="lt-tab-panel"> <div id="tab-workers" class="lt-tab-panel" role="tabpanel" aria-labelledby="tab-btn-workers">
<div class="lt-grid-3"> <div class="lt-grid-3">
<div class="lt-card"> <div class="lt-card">
@@ -644,11 +647,13 @@
</div> </div>
<div class="lt-data-table-wrapper"> <div class="lt-data-table-wrapper">
<table class="lt-data-table"> <table class="lt-data-table">
<tbody>
<tr><td class="lt-text-muted">CPU</td> <td>12%</td></tr> <tr><td class="lt-text-muted">CPU</td> <td>12%</td></tr>
<tr><td class="lt-text-muted">Memory</td> <td>2.1 GB / 8 GB</td></tr> <tr><td class="lt-text-muted">Memory</td> <td>2.1 GB / 8 GB</td></tr>
<tr><td class="lt-text-muted">Load</td> <td>0.42 / 0.51 / 0.48</td></tr> <tr><td class="lt-text-muted">Load</td> <td>0.42 / 0.51 / 0.48</td></tr>
<tr><td class="lt-text-muted">Uptime</td> <td>14d 6h</td></tr> <tr><td class="lt-text-muted">Uptime</td> <td>14d 6h</td></tr>
<tr><td class="lt-text-muted">Tasks</td> <td>2 / 5</td></tr> <tr><td class="lt-text-muted">Tasks</td> <td>2 / 5</td></tr>
</tbody>
</table> </table>
</div> </div>
</div> </div>
@@ -783,7 +788,7 @@
<div class="lt-search lt-form-group"> <div class="lt-search lt-form-group">
<label class="lt-label" for="eg-search">Search</label> <label class="lt-label" for="eg-search">Search</label>
<input id="eg-search" type="search" class="lt-input lt-search-input" <input id="eg-search" type="search" class="lt-input lt-search-input"
placeholder="Ctrl+K to focus"> placeholder="Ctrl+K to focus" autocomplete="off">
</div> </div>
<div class="lt-form-group"> <div class="lt-form-group">
<label class="lt-label">Loading state (skeleton)</label> <label class="lt-label">Loading state (skeleton)</label>
@@ -834,19 +839,19 @@
<div style="display:flex;flex-direction:column;gap:var(--space-md)"> <div style="display:flex;flex-direction:column;gap:var(--space-md)">
<div> <div>
<div class="lt-progress-label"><span>CPU LOAD</span><span>72%</span></div> <div class="lt-progress-label"><span>CPU LOAD</span><span>72%</span></div>
<div class="lt-progress"><div class="lt-progress-bar" style="width:72%" data-width="72%"></div></div> <div class="lt-progress" role="progressbar" aria-valuenow="72" aria-valuemin="0" aria-valuemax="100" aria-label="CPU Load"><div class="lt-progress-bar" style="width:72%" data-width="72%"></div></div>
</div> </div>
<div> <div>
<div class="lt-progress-label"><span>MEMORY</span><span>45%</span></div> <div class="lt-progress-label"><span>MEMORY</span><span>45%</span></div>
<div class="lt-progress lt-progress--cyan"><div class="lt-progress-bar" style="width:45%" data-width="45%"></div></div> <div class="lt-progress lt-progress--cyan" role="progressbar" aria-valuenow="45" aria-valuemin="0" aria-valuemax="100" aria-label="Memory"><div class="lt-progress-bar" style="width:45%" data-width="45%"></div></div>
</div> </div>
<div> <div>
<div class="lt-progress-label"><span>DISK I/O</span><span>89%</span></div> <div class="lt-progress-label"><span>DISK I/O</span><span>89%</span></div>
<div class="lt-progress lt-progress--red lt-progress--lg"><div class="lt-progress-bar" style="width:89%" data-width="89%"></div></div> <div class="lt-progress lt-progress--red lt-progress--lg" role="progressbar" aria-valuenow="89" aria-valuemin="0" aria-valuemax="100" aria-label="Disk I/O"><div class="lt-progress-bar" style="width:89%" data-width="89%"></div></div>
</div> </div>
<div> <div>
<div class="lt-progress-label"><span>UPTIME</span><span>100%</span></div> <div class="lt-progress-label"><span>UPTIME</span><span>100%</span></div>
<div class="lt-progress lt-progress--green lt-progress--striped"><div class="lt-progress-bar" style="width:100%" data-width="100%"></div></div> <div class="lt-progress lt-progress--green lt-progress--striped" role="progressbar" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100" aria-label="Uptime"><div class="lt-progress-bar" style="width:100%" data-width="100%"></div></div>
</div> </div>
</div> </div>
</div> </div>
@@ -871,25 +876,25 @@
<div class="lt-section-body"> <div class="lt-section-body">
<div> <div>
<div class="lt-accordion"> <div class="lt-accordion">
<button class="lt-accordion-header" aria-expanded="false" data-accordion> <button class="lt-accordion-header" aria-expanded="false" aria-controls="acc-body-1" data-accordion>
SYSTEM OVERVIEW SYSTEM OVERVIEW
<svg class="lt-accordion-icon" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 1l4 4 4-4"/></svg> <svg class="lt-accordion-icon" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 1l4 4 4-4"/></svg>
</button> </button>
<div class="lt-accordion-body"><div class="lt-accordion-content">Node running at 72% CPU. 12 active processes. Last restart: 3d ago.</div></div> <div class="lt-accordion-body" id="acc-body-1"><div class="lt-accordion-content">Node running at 72% CPU. 12 active processes. Last restart: 3d ago.</div></div>
</div> </div>
<div class="lt-accordion"> <div class="lt-accordion">
<button class="lt-accordion-header" aria-expanded="false" data-accordion> <button class="lt-accordion-header" aria-expanded="false" aria-controls="acc-body-2" data-accordion>
NETWORK CONFIG NETWORK CONFIG
<svg class="lt-accordion-icon" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 1l4 4 4-4"/></svg> <svg class="lt-accordion-icon" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 1l4 4 4-4"/></svg>
</button> </button>
<div class="lt-accordion-body"><div class="lt-accordion-content">eth0: 10.0.0.7 — MTU 1500 — RX 4.2 GB — TX 1.1 GB</div></div> <div class="lt-accordion-body" id="acc-body-2"><div class="lt-accordion-content">eth0: 10.0.0.7 — MTU 1500 — RX 4.2 GB — TX 1.1 GB</div></div>
</div> </div>
<div class="lt-accordion"> <div class="lt-accordion">
<button class="lt-accordion-header" aria-expanded="false" data-accordion> <button class="lt-accordion-header" aria-expanded="false" aria-controls="acc-body-3" data-accordion>
FIREWALL RULES FIREWALL RULES
<svg class="lt-accordion-icon" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 1l4 4 4-4"/></svg> <svg class="lt-accordion-icon" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 1l4 4 4-4"/></svg>
</button> </button>
<div class="lt-accordion-body"><div class="lt-accordion-content">22/tcp ALLOW — 80/tcp ALLOW — 443/tcp ALLOW — */* DENY</div></div> <div class="lt-accordion-body" id="acc-body-3"><div class="lt-accordion-content">22/tcp ALLOW — 80/tcp ALLOW — 443/tcp ALLOW — */* DENY</div></div>
</div> </div>
</div> </div>
</div> </div>
@@ -1059,20 +1064,20 @@
<div class="lt-section-header">Tab Bar</div> <div class="lt-section-header">Tab Bar</div>
<div class="lt-section-body"> <div class="lt-section-body">
<div class="lt-tab-bar" role="tablist"> <div class="lt-tab-bar" role="tablist">
<button class="lt-tab active" role="tab" data-tab-target="tab-overview" aria-selected="true">Overview</button> <button class="lt-tab active" role="tab" id="tab2-btn-overview" data-tab-target="tab-overview" aria-selected="true" aria-controls="tab-overview">Overview</button>
<button class="lt-tab" role="tab" data-tab-target="tab-logs" aria-selected="false">Logs</button> <button class="lt-tab" role="tab" id="tab2-btn-logs" data-tab-target="tab-logs" aria-selected="false" aria-controls="tab-logs">Logs</button>
<button class="lt-tab" role="tab" data-tab-target="tab-metrics" aria-selected="false">Metrics</button> <button class="lt-tab" role="tab" id="tab2-btn-metrics" data-tab-target="tab-metrics" aria-selected="false" aria-controls="tab-metrics">Metrics</button>
<button class="lt-tab" role="tab" data-tab-target="tab-config" aria-selected="false">Config</button> <button class="lt-tab" role="tab" id="tab2-btn-config" data-tab-target="tab-config" aria-selected="false" aria-controls="tab-config">Config</button>
</div> </div>
<div class="lt-tab-panels" style="padding:var(--space-md) 0"> <div class="lt-tab-panels" style="padding:var(--space-md) 0">
<div id="tab-overview" class="lt-tab-panel active">System overview: all nodes nominal. 14 active sessions.</div> <div id="tab-overview" class="lt-tab-panel active" role="tabpanel" aria-labelledby="tab2-btn-overview">System overview: all nodes nominal. 14 active sessions.</div>
<div id="tab-logs" class="lt-tab-panel"> <div id="tab-logs" class="lt-tab-panel" role="tabpanel" aria-labelledby="tab2-btn-logs">
<pre style="font-family:var(--font-mono);font-size:0.75rem;color:var(--text-secondary);margin:0">[03:41:22] nginx: 200 GET /api/nodes (12ms) <pre style="font-family:var(--font-mono);font-size:0.75rem;color:var(--text-secondary);margin:0">[03:41:22] nginx: 200 GET /api/nodes (12ms)
[03:41:23] cron: heartbeat OK [03:41:23] cron: heartbeat OK
[03:41:24] alert: disk 87% on node-03</pre> [03:41:24] alert: disk 87% on node-03</pre>
</div> </div>
<div id="tab-metrics" class="lt-tab-panel">CPU avg: 34% — Mem avg: 61% — Net TX: 4.2 Mbps</div> <div id="tab-metrics" class="lt-tab-panel" role="tabpanel" aria-labelledby="tab2-btn-metrics">CPU avg: 34% — Mem avg: 61% — Net TX: 4.2 Mbps</div>
<div id="tab-config" class="lt-tab-panel">Config last updated: 2026-03-20 by admin@lotusguild.io</div> <div id="tab-config" class="lt-tab-panel" role="tabpanel" aria-labelledby="tab2-btn-config">Config last updated: 2026-03-20 by admin@lotusguild.io</div>
</div> </div>
</div> </div>
@@ -1107,7 +1112,7 @@
<p style="font-family:var(--font-mono);font-size:0.8rem;color:var(--text-secondary);margin:0 0 var(--space-sm)"> <p style="font-family:var(--font-mono);font-size:0.8rem;color:var(--text-secondary);margin:0 0 var(--space-sm)">
Press <kbd style="background:var(--bg-tertiary);border:1px solid var(--border-dim);padding:2px 6px;font-family:var(--font-mono)">Ctrl+K</kbd> or click the button below to open the command palette. Press <kbd style="background:var(--bg-tertiary);border:1px solid var(--border-dim);padding:2px 6px;font-family:var(--font-mono)">Ctrl+K</kbd> or click the button below to open the command palette.
</p> </p>
<button class="lt-btn lt-btn-ghost" onclick="lt.cmdPalette.open()">⌘ Open Command Palette</button> <button type="button" class="lt-btn lt-btn-ghost" onclick="lt.cmdPalette.open()">⌘ Open Command Palette</button>
</div> </div>
</div><!-- /.lt-frame-inner (v1.2 additions) --> </div><!-- /.lt-frame-inner (v1.2 additions) -->
@@ -1126,9 +1131,9 @@
<div class="lt-section-body"> <div class="lt-section-body">
<p style="font-size:0.78rem;color:var(--text-secondary);margin-bottom:1rem">Dark/light mode with OS preference detection and localStorage persistence.</p> <p style="font-size:0.78rem;color:var(--text-secondary);margin-bottom:1rem">Dark/light mode with OS preference detection and localStorage persistence.</p>
<div class="lt-flex lt-gap-sm lt-align-center lt-wrap"> <div class="lt-flex lt-gap-sm lt-align-center lt-wrap">
<button class="lt-btn lt-btn-primary" onclick="lt.theme.toggle()">Toggle Theme</button> <button type="button" class="lt-btn lt-btn-primary" onclick="lt.theme.toggle()">Toggle Theme</button>
<button class="lt-btn" onclick="lt.theme.set('dark')">Force Dark</button> <button type="button" class="lt-btn" onclick="lt.theme.set('dark')">Force Dark</button>
<button class="lt-btn" onclick="lt.theme.set('light')">Force Light</button> <button type="button" class="lt-btn" onclick="lt.theme.set('light')">Force Light</button>
<code style="font-size:0.72rem;color:var(--accent-cyan)">lt.theme.toggle() | .set('light') | .get()</code> <code style="font-size:0.72rem;color:var(--accent-cyan)">lt.theme.toggle() | .set('light') | .get()</code>
</div> </div>
</div> </div>
@@ -1171,7 +1176,7 @@
<div class="lt-section-body"> <div class="lt-section-body">
<div class="lt-grid lt-grid-3" style="gap:1rem"> <div class="lt-grid lt-grid-3" style="gap:1rem">
<div class="lt-card"><div class="lt-empty-state"><div class="lt-empty-state-icon">📭</div><div class="lt-empty-state-title">No Tickets Found</div><div class="lt-empty-state-body">No tickets match your current filters.</div><button class="lt-btn lt-btn-sm lt-btn-primary">Clear Filters</button></div></div> <div class="lt-card"><div class="lt-empty-state"><div class="lt-empty-state-icon">📭</div><div class="lt-empty-state-title">No Tickets Found</div><div class="lt-empty-state-body">No tickets match your current filters.</div><button class="lt-btn lt-btn-sm lt-btn-primary">Clear Filters</button></div></div>
<div class="lt-card"><div class="lt-empty-state"><div class="lt-empty-state-icon">🔌</div><div class="lt-empty-state-title">No Workers Online</div><div class="lt-empty-state-body">All workers are offline or unreachable.</div><button class="lt-btn lt-btn-sm" onclick="lt.toast.warning('Checking workers…')">Retry</button></div></div> <div class="lt-card"><div class="lt-empty-state"><div class="lt-empty-state-icon">🔌</div><div class="lt-empty-state-title">No Workers Online</div><div class="lt-empty-state-body">All workers are offline or unreachable.</div><button type="button" class="lt-btn lt-btn-sm" onclick="lt.toast.warning('Checking workers…')">Retry</button></div></div>
<div class="lt-card"><div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-icon">🗂</div><div class="lt-empty-state-title">No Results</div><div class="lt-empty-state-body">Try a different search term.</div></div></div> <div class="lt-card"><div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-icon">🗂</div><div class="lt-empty-state-title">No Results</div><div class="lt-empty-state-body">Try a different search term.</div></div></div>
</div> </div>
</div> </div>
@@ -1200,9 +1205,9 @@
</div> </div>
<div class="lt-flex lt-gap-md lt-align-center lt-wrap"> <div class="lt-flex lt-gap-md lt-align-center lt-wrap">
<span class="lt-notif-wrap" id="demo-notif-btn"><button class="lt-btn lt-btn-sm">🔔 Alerts</button></span> <span class="lt-notif-wrap" id="demo-notif-btn"><button class="lt-btn lt-btn-sm">🔔 Alerts</button></span>
<button class="lt-btn lt-btn-sm" onclick="lt.notif.inc('#demo-notif-btn')">+1 Badge</button> <button type="button" class="lt-btn lt-btn-sm" onclick="lt.notif.inc('#demo-notif-btn')">+1 Badge</button>
<button class="lt-btn lt-btn-sm" onclick="lt.notif.clear('#demo-notif-btn')">Clear</button> <button type="button" class="lt-btn lt-btn-sm" onclick="lt.notif.clear('#demo-notif-btn')">Clear</button>
<button class="lt-btn lt-btn-sm" onclick="lt.notif.inc('#lt-notif-bell')">+1 Header Bell</button> <button type="button" class="lt-btn lt-btn-sm" onclick="lt.notif.inc('#lt-notif-bell')">+1 Header Bell</button>
</div> </div>
</div> </div>
</div> </div>
@@ -1380,8 +1385,8 @@
<div class="lt-ws-status" data-state="disconnected"><span class="lt-dot"></span><span>Disconnected</span></div> <div class="lt-ws-status" data-state="disconnected"><span class="lt-dot"></span><span>Disconnected</span></div>
</div> </div>
<div class="lt-flex lt-gap-sm lt-wrap"> <div class="lt-flex lt-gap-sm lt-wrap">
<button class="lt-btn lt-btn-sm" onclick="lt.offline.isOnline() ? lt.toast.success('Online ✓') : lt.toast.error('Offline ✗')">Check Online Status</button> <button type="button" class="lt-btn lt-btn-sm" onclick="lt.offline.isOnline() ? lt.toast.success('Online ✓') : lt.toast.error('Offline ✗')">Check Online Status</button>
<button class="lt-btn lt-btn-sm" onclick="lt.toast.info('lt.ws.connect(url, { reconnect:true, onMessage: fn })')">WS API Hint</button> <button type="button" class="lt-btn lt-btn-sm" onclick="lt.toast.info('lt.ws.connect(url, { reconnect:true, onMessage: fn })')">WS API Hint</button>
</div> </div>
<p style="font-size:0.72rem;color:var(--text-muted);margin-top:0.75rem">Offline banner + body class auto-applied on <code>navigator.onLine</code> change. WS manager has exponential backoff, event emitter, status indicator binding.</p> <p style="font-size:0.72rem;color:var(--text-muted);margin-top:0.75rem">Offline banner + body class auto-applied on <code>navigator.onLine</code> change. WS manager has exponential backoff, event emitter, status indicator binding.</p>
</div> </div>
@@ -1397,7 +1402,7 @@
<div id="demo-wizard"> <div id="demo-wizard">
<!-- Step indicators --> <!-- Step indicators -->
<div class="lt-wizard-steps"> <div class="lt-wizard-steps">
<div class="lt-wizard-step is-active" data-wizard-indicator> <div class="lt-wizard-step is-active" data-wizard-indicator aria-current="step">
<div class="lt-wizard-num">1</div> <div class="lt-wizard-num">1</div>
<div class="lt-wizard-label">Details</div> <div class="lt-wizard-label">Details</div>
</div> </div>
@@ -1574,24 +1579,36 @@ Storage array link-down on `compute-storage-01`.
</main><!-- /.lt-main --> </main><!-- /.lt-main -->
<!-- ================================================================
FOOTER LANDMARK
================================================================ -->
<footer class="lt-footer" role="contentinfo" aria-label="Application footer">
<span class="lt-text-muted lt-font-mono lt-text-xs">
LotusGuild Terminal Design System v1.2 &mdash; Internal Use Only
</span>
<span class="lt-text-muted lt-font-mono lt-text-xs">
&copy; <span id="footer-year"></span> LotusGuild. All rights reserved.
</span>
</footer>
<!-- =========================================================== <!-- ===========================================================
TOAST DEMO BUTTONS (remove in production) TOAST DEMO BUTTONS (remove in production)
=========================================================== --> =========================================================== -->
<div style="position:fixed;bottom:1rem;left:1rem;display:flex;flex-direction:column;gap:0.5rem;z-index:900"> <div style="position:fixed;bottom:1rem;left:1rem;display:flex;flex-direction:column;gap:0.5rem;z-index:900">
<button class="lt-btn lt-btn-sm" onclick="lt.toast.success('Ticket saved successfully')">✓ Toast</button> <button type="button" class="lt-btn lt-btn-sm" onclick="lt.toast.success('Ticket saved successfully')">✓ Toast</button>
<button class="lt-btn lt-btn-sm lt-btn-danger" onclick="lt.toast.error('Network error — retry in 5s', 5000)">✗ Error</button> <button type="button" class="lt-btn lt-btn-sm lt-btn-danger" onclick="lt.toast.error('Network error — retry in 5s', 5000)">✗ Error</button>
<button class="lt-btn lt-btn-sm" onclick="lt.toast.warning('Rate limit 80% used')">! Warn</button> <button type="button" class="lt-btn lt-btn-sm" onclick="lt.toast.warning('Rate limit 80% used')">! Warn</button>
<button class="lt-btn lt-btn-sm lt-btn-ghost" onclick="lt.toast.info('Auto-refresh triggered')">i Info</button> <button type="button" class="lt-btn lt-btn-sm lt-btn-ghost" onclick="lt.toast.info('Auto-refresh triggered')">i Info</button>
</div> </div>
<!-- =========================================================== <!-- ===========================================================
MODAL EXAMPLE: Export MODAL EXAMPLE: Export
=========================================================== --> =========================================================== -->
<div id="export-modal" class="lt-modal-overlay" aria-hidden="true"> <div id="export-modal" class="lt-modal-overlay" aria-hidden="true">
<div class="lt-modal"> <div class="lt-modal" role="dialog" aria-modal="true" aria-labelledby="export-modal-title">
<div class="lt-modal-header"> <div class="lt-modal-header">
<span class="lt-modal-title">Export Tickets</span> <span class="lt-modal-title" id="export-modal-title">Export Tickets</span>
<button class="lt-modal-close" data-modal-close aria-label="Close"></button> <button class="lt-modal-close" data-modal-close aria-label="Close"></button>
</div> </div>
<div class="lt-modal-body"> <div class="lt-modal-body">
@@ -1610,8 +1627,8 @@ Storage array link-down on `compute-storage-01`.
<div class="lt-msg lt-msg-info">Exports include all visible columns.</div> <div class="lt-msg lt-msg-info">Exports include all visible columns.</div>
</div> </div>
<div class="lt-modal-footer"> <div class="lt-modal-footer">
<button class="lt-btn lt-btn-ghost" data-modal-close>Cancel</button> <button type="button" class="lt-btn lt-btn-ghost" data-modal-close>Cancel</button>
<button class="lt-btn lt-btn-primary" onclick="lt.toast.success('Export started'); lt.modal.close('export-modal')">Export</button> <button type="button" class="lt-btn lt-btn-primary" onclick="lt.toast.success('Export started'); lt.modal.close('export-modal')">Export</button>
</div> </div>
</div> </div>
</div> </div>
@@ -1620,9 +1637,9 @@ Storage array link-down on `compute-storage-01`.
MODAL EXAMPLE: Keyboard shortcuts help MODAL EXAMPLE: Keyboard shortcuts help
=========================================================== --> =========================================================== -->
<div id="lt-keys-help" class="lt-modal-overlay" aria-hidden="true"> <div id="lt-keys-help" class="lt-modal-overlay" aria-hidden="true">
<div class="lt-modal"> <div class="lt-modal" role="dialog" aria-modal="true" aria-labelledby="keys-help-title">
<div class="lt-modal-header"> <div class="lt-modal-header">
<span class="lt-modal-title">Keyboard Shortcuts</span> <span class="lt-modal-title" id="keys-help-title">Keyboard Shortcuts</span>
<button class="lt-modal-close" data-modal-close aria-label="Close"></button> <button class="lt-modal-close" data-modal-close aria-label="Close"></button>
</div> </div>
<div class="lt-modal-body"> <div class="lt-modal-body">
@@ -1932,6 +1949,10 @@ Storage array link-down on `compute-storage-01`.
}); });
}); });
// Footer year
const footerYear = document.getElementById('footer-year');
if (footerYear) footerYear.textContent = new Date().getFullYear();
// Tab bar switching // Tab bar switching
document.querySelectorAll('.lt-tab-bar').forEach(bar => { document.querySelectorAll('.lt-tab-bar').forEach(bar => {
bar.addEventListener('click', e => { bar.addEventListener('click', e => {
+91 -22
View File
@@ -73,7 +73,7 @@
function showToast(message, type, duration) { function showToast(message, type, duration) {
type = type || 'info'; type = type || 'info';
duration = duration || 3500; duration = duration || 3500;
if (_toastActive) { _toastQueue.push({ message, type, duration }); return; } if (_toastActive) { if (_toastQueue.length < 12) _toastQueue.push({ message, type, duration }); return; }
_displayToast(message, type, duration); _displayToast(message, type, duration);
} }
@@ -214,7 +214,7 @@
_modalTriggers.set(el, document.activeElement); _modalTriggers.set(el, document.activeElement);
} }
el.classList.add('is-open'); el.classList.add('is-open');
el.setAttribute('aria-hidden', 'false'); el.removeAttribute('aria-hidden'); /* removing is correct; setting 'false' is an anti-pattern */
_lockScroll(); _lockScroll();
// Focus first focusable element // Focus first focusable element
const first = el.querySelector(_FOCUSABLE); const first = el.querySelector(_FOCUSABLE);
@@ -263,16 +263,21 @@
lt.tabs.switch('panel-id') lt.tabs.switch('panel-id')
---------------------------------------------------------------- */ ---------------------------------------------------------------- */
function switchTab(panelId) { function switchTab(panelId) {
document.querySelectorAll('.lt-tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.lt-tab[data-tab]').forEach(t => {
t.classList.remove('active');
t.setAttribute('aria-selected', 'false');
});
document.querySelectorAll('.lt-tab-panel').forEach(p => p.classList.remove('active')); document.querySelectorAll('.lt-tab-panel').forEach(p => p.classList.remove('active'));
const btn = document.querySelector('.lt-tab[data-tab="' + panelId + '"]'); const btn = document.querySelector('.lt-tab[data-tab="' + panelId + '"]');
const panel = document.getElementById(panelId); const panel = document.getElementById(panelId);
if (btn) btn.classList.add('active'); if (btn) { btn.classList.add('active'); btn.setAttribute('aria-selected', 'true'); }
if (panel) panel.classList.add('active'); if (panel) panel.classList.add('active');
try { localStorage.setItem('lt_activeTab_' + location.pathname, panelId); } catch (_) {} try { localStorage.setItem('lt_activeTab_' + location.pathname, panelId); } catch (_) {}
} }
let _tabsInitialized = false;
function initTabs() { function initTabs() {
if (_tabsInitialized) return; _tabsInitialized = true;
try { try {
const saved = localStorage.getItem('lt_activeTab_' + location.pathname); const saved = localStorage.getItem('lt_activeTab_' + location.pathname);
if (saved && document.getElementById(saved)) { switchTab(saved); } if (saved && document.getElementById(saved)) { switchTab(saved); }
@@ -392,7 +397,9 @@
---------------------------------------------------------------- ----------------------------------------------------------------
lt.sidebar.init() lt.sidebar.init()
---------------------------------------------------------------- */ ---------------------------------------------------------------- */
let _sidebarInitialized = false;
function initSidebar() { function initSidebar() {
if (_sidebarInitialized) return; _sidebarInitialized = true;
document.querySelectorAll('[data-sidebar-toggle]').forEach(btn => { document.querySelectorAll('[data-sidebar-toggle]').forEach(btn => {
const sidebar = document.getElementById(btn.dataset.sidebarToggle); const sidebar = document.getElementById(btn.dataset.sidebarToggle);
if (!sidebar) return; if (!sidebar) return;
@@ -429,8 +436,11 @@
lt.api.put / patch / delete lt.api.put / patch / delete
---------------------------------------------------------------- */ ---------------------------------------------------------------- */
async function apiFetch(method, url, body) { async function apiFetch(method, url, body) {
const opts = { method, headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()) }; const hasBody = body !== undefined;
if (body !== undefined) opts.body = JSON.stringify(body); const headers = Object.assign({}, csrfHeaders());
if (hasBody) headers['Content-Type'] = 'application/json'; // Only set on requests with a body
const opts = { method, headers };
if (hasBody) opts.body = JSON.stringify(body);
let resp; let resp;
try { resp = await fetch(url, opts); } catch (err) { throw new Error('Network error: ' + err.message); } try { resp = await fetch(url, opts); } catch (err) { throw new Error('Network error: ' + err.message); }
let data; let data;
@@ -536,9 +546,11 @@
const ths = Array.from(table.querySelectorAll('th[data-sort-key]')); const ths = Array.from(table.querySelectorAll('th[data-sort-key]'));
ths.forEach((th, colIdx) => { ths.forEach((th, colIdx) => {
let dir = 'asc'; let dir = 'asc';
th.setAttribute('aria-sort', 'none');
th.addEventListener('click', () => { th.addEventListener('click', () => {
ths.forEach(h => h.removeAttribute('data-sort')); ths.forEach(h => { h.removeAttribute('data-sort'); h.setAttribute('aria-sort', 'none'); });
th.setAttribute('data-sort', dir); th.setAttribute('data-sort', dir);
th.setAttribute('aria-sort', dir === 'asc' ? 'ascending' : 'descending');
const tbody = table.querySelector('tbody'); const tbody = table.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr')); const rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort((a, b) => { rows.sort((a, b) => {
@@ -625,7 +637,9 @@
}); });
} }
let _accordionInitialized = false;
function initAccordion() { function initAccordion() {
if (_accordionInitialized) return; _accordionInitialized = true;
// Support both data-accordion attribute (HTML) and .lt-accordion-trigger class // Support both data-accordion attribute (HTML) and .lt-accordion-trigger class
document.querySelectorAll('[data-accordion], .lt-accordion-trigger').forEach(trigger => { document.querySelectorAll('[data-accordion], .lt-accordion-trigger').forEach(trigger => {
if (trigger.getAttribute('aria-expanded') === 'true') { if (trigger.getAttribute('aria-expanded') === 'true') {
@@ -680,7 +694,11 @@
case 'right': top = r.top + sy + r.height / 2 - tr.height / 2; left = r.right + sx + 8; break; case 'right': top = r.top + sy + r.height / 2 - tr.height / 2; left = r.right + sx + 8; break;
default: top = r.top + sy - tr.height - 8; left = r.left + sx + r.width / 2 - tr.width / 2; default: top = r.top + sy - tr.height - 8; left = r.left + sx + r.width / 2 - tr.width / 2;
} }
tip.style.cssText = 'position:absolute;top:' + Math.max(4, top) + 'px;left:' + Math.max(4, left) + 'px;z-index:9000'; const maxLeft = (global.scrollX || 0) + global.innerWidth - tr.width - 4;
const maxTop = (global.scrollY || 0) + global.innerHeight - tr.height - 4;
left = Math.max(4 + (global.scrollX || 0), Math.min(maxLeft, left));
top = Math.max(4 + (global.scrollY || 0), Math.min(maxTop, top));
tip.style.cssText = 'position:absolute;top:' + top + 'px;left:' + left + 'px';
requestAnimationFrame(() => tip.classList.add('is-visible')); requestAnimationFrame(() => tip.classList.add('is-visible'));
} }
@@ -688,7 +706,10 @@
if (_tooltipEl) { _tooltipEl.remove(); _tooltipEl = null; } if (_tooltipEl) { _tooltipEl.remove(); _tooltipEl = null; }
} }
let _tooltipInitialized = false;
function initTooltips() { function initTooltips() {
if (_tooltipInitialized) return;
_tooltipInitialized = true;
document.addEventListener('mouseover', e => { const a = e.target.closest('[data-tooltip]'); if (a) _tooltipShow(a); }); document.addEventListener('mouseover', e => { const a = e.target.closest('[data-tooltip]'); if (a) _tooltipShow(a); });
document.addEventListener('mouseout', e => { if (!e.target.closest('[data-tooltip]')) return; if (!e.relatedTarget || !e.relatedTarget.closest('[data-tooltip]')) _tooltipHide(); }); document.addEventListener('mouseout', e => { if (!e.target.closest('[data-tooltip]')) return; if (!e.relatedTarget || !e.relatedTarget.closest('[data-tooltip]')) _tooltipHide(); });
document.addEventListener('focusin', e => { const a = e.target.closest('[data-tooltip]'); if (a) _tooltipShow(a); }); document.addEventListener('focusin', e => { const a = e.target.closest('[data-tooltip]'); if (a) _tooltipShow(a); });
@@ -718,7 +739,9 @@
} catch (_) { return false; } } catch (_) { return false; }
} }
let _copyInitialized = false;
function initCopyButtons() { function initCopyButtons() {
if (_copyInitialized) return; _copyInitialized = true;
document.addEventListener('click', async function (e) { document.addEventListener('click', async function (e) {
const btn = e.target.closest('[data-copy]'); if (!btn) return; const btn = e.target.closest('[data-copy]'); if (!btn) return;
const orig = btn.textContent; const orig = btn.textContent;
@@ -726,7 +749,7 @@
if (ok) { if (ok) {
btn.textContent = 'COPIED ✓'; btn.disabled = true; btn.textContent = 'COPIED ✓'; btn.disabled = true;
if (btn.hasAttribute('data-copy-toast')) toast.success('Copied to clipboard'); if (btn.hasAttribute('data-copy-toast')) toast.success('Copied to clipboard');
setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 1500); setTimeout(() => { if (document.contains(btn)) { btn.textContent = orig; btn.disabled = false; } }, 1500);
} else { toast.error('Copy failed'); } } else { toast.error('Copy failed'); }
}); });
} }
@@ -750,9 +773,11 @@
})); }));
} }
let _alertsInitialized = false;
function initAlerts() { function initAlerts() {
if (_alertsInitialized) return; _alertsInitialized = true;
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
const btn = e.target.closest('.lt-alert-dismiss'); if (!btn) return; const btn = e.target.closest('.lt-alert-close, .lt-alert-dismiss'); if (!btn) return;
const al = btn.closest('.lt-alert'); if (al) dismissAlert(al); const al = btn.closest('.lt-alert'); if (al) dismissAlert(al);
}); });
document.querySelectorAll('.lt-alert[data-alert-auto-dismiss]').forEach(el => { document.querySelectorAll('.lt-alert[data-alert-auto-dismiss]').forEach(el => {
@@ -805,12 +830,13 @@
Command: { id, label, icon?, description?, kbd?, group?, tags?, action } Command: { id, label, icon?, description?, kbd?, group?, tags?, action }
---------------------------------------------------------------- */ ---------------------------------------------------------------- */
let _cpCommands = [], _cpSelected = 0; let _cpCommands = [], _cpSelected = 0, _cpTrigger = null;
const _cpRecentKey = 'lt_cmd_recent'; const _cpRecentKey = 'lt_cmd_recent';
function _cmdPaletteOpen() { function _cmdPaletteOpen() {
const ov = document.getElementById('lt-cmd-overlay'); if (!ov) return; const ov = document.getElementById('lt-cmd-overlay'); if (!ov) return;
if (_mnOpen) _mnSetOpen(false); if (_mnOpen) _mnSetOpen(false);
_cpTrigger = (document.activeElement && document.activeElement !== document.body) ? document.activeElement : null;
ov.classList.add('is-open'); ov.classList.add('is-open');
_lockScroll(); _lockScroll();
const palette = document.getElementById('lt-cmd-palette'); const palette = document.getElementById('lt-cmd-palette');
@@ -823,6 +849,7 @@
const ov = document.getElementById('lt-cmd-overlay'); if (!ov) return; const ov = document.getElementById('lt-cmd-overlay'); if (!ov) return;
ov.classList.remove('is-open'); ov.classList.remove('is-open');
_unlockScroll(); _unlockScroll();
if (_cpTrigger) { _cpTrigger.focus(); _cpTrigger = null; }
} }
function _cpHighlight(text, q) { function _cpHighlight(text, q) {
@@ -960,12 +987,22 @@
function _showError(el, msg) { function _showError(el, msg) {
el.classList.add('is-invalid'); el.classList.remove('is-valid'); el.classList.add('is-invalid'); el.classList.remove('is-valid');
let err = el.parentElement && el.parentElement.querySelector('.lt-field-error'); let err = el.parentElement && el.parentElement.querySelector('.lt-field-error');
if (!err) { err = document.createElement('span'); err.className = 'lt-field-error'; if (el.parentElement) el.parentElement.appendChild(err); } if (!err) {
err = document.createElement('span');
err.className = 'lt-field-error';
err.id = (el.id || ('lt-field-' + Math.random().toString(36).slice(2))) + '-err';
if (el.parentElement) el.parentElement.appendChild(err);
}
err.textContent = msg; err.textContent = msg;
err.setAttribute('role', 'alert');
el.setAttribute('aria-describedby', err.id);
el.setAttribute('aria-invalid', 'true');
} }
function _clearError(el) { function _clearError(el) {
el.classList.remove('is-invalid'); el.classList.add('is-valid'); el.classList.remove('is-invalid'); el.classList.add('is-valid');
el.removeAttribute('aria-invalid');
el.removeAttribute('aria-describedby');
const err = el.parentElement && el.parentElement.querySelector('.lt-field-error'); const err = el.parentElement && el.parentElement.querySelector('.lt-field-error');
if (err) err.remove(); if (err) err.remove();
} }
@@ -990,7 +1027,7 @@
e.preventDefault(); e.preventDefault();
const r = _validateForm(formEl); const r = _validateForm(formEl);
if (r.valid && typeof onSubmit === 'function') onSubmit(formEl, e); if (r.valid && typeof onSubmit === 'function') onSubmit(formEl, e);
else if (!r.valid) r.errors[0].el.focus(); else if (!r.valid && r.errors.length) r.errors[0].el.focus();
}); });
} }
@@ -1550,9 +1587,11 @@
if (triggerEl) _modalTriggers.set(drawer, triggerEl); if (triggerEl) _modalTriggers.set(drawer, triggerEl);
const first = drawer.querySelector(_FOCUSABLE); const first = drawer.querySelector(_FOCUSABLE);
if (first) setTimeout(() => first.focus(), 50); if (first) setTimeout(() => first.focus(), 50);
// ESC to close // ESC to close + Tab trap
drawer._rdKeyHandler = e => { if (e.key === 'Escape') _rdClose(drawer); }; drawer._rdKeyHandler = e => { if (e.key === 'Escape') _rdClose(drawer); };
document.addEventListener('keydown', drawer._rdKeyHandler); document.addEventListener('keydown', drawer._rdKeyHandler);
drawer._rdTrapHandler = e => { if (e.key === 'Tab') _trapFocus(drawer, e); };
drawer.addEventListener('keydown', drawer._rdTrapHandler);
// Overlay click // Overlay click
if (ov) ov._rdClick = () => _rdClose(drawer); if (ov) ov._rdClick = () => _rdClose(drawer);
if (ov) ov.addEventListener('click', ov._rdClick); if (ov) ov.addEventListener('click', ov._rdClick);
@@ -1570,8 +1609,12 @@
drawer.classList.remove('is-open'); drawer.classList.remove('is-open');
drawer.setAttribute('aria-hidden', 'true'); drawer.setAttribute('aria-hidden', 'true');
if (ov) { ov.classList.remove('is-open'); if (ov._rdClick) { ov.removeEventListener('click', ov._rdClick); delete ov._rdClick; } } if (ov) { ov.classList.remove('is-open'); if (ov._rdClick) { ov.removeEventListener('click', ov._rdClick); delete ov._rdClick; } }
drawer.querySelectorAll('[data-drawer-close]').forEach(btn => {
if (btn._rdHandler) { btn.removeEventListener('click', btn._rdHandler); delete btn._rdHandler; }
});
_unlockScroll(); _unlockScroll();
if (drawer._rdKeyHandler) { document.removeEventListener('keydown', drawer._rdKeyHandler); delete drawer._rdKeyHandler; } if (drawer._rdKeyHandler) { document.removeEventListener('keydown', drawer._rdKeyHandler); delete drawer._rdKeyHandler; }
if (drawer._rdTrapHandler) { drawer.removeEventListener('keydown', drawer._rdTrapHandler); delete drawer._rdTrapHandler; }
const trigger = _modalTriggers.get(drawer); const trigger = _modalTriggers.get(drawer);
if (trigger) { trigger.focus(); _modalTriggers.delete(drawer); } if (trigger) { trigger.focus(); _modalTriggers.delete(drawer); }
} }
@@ -1791,6 +1834,15 @@
let focusedIdx = -1; let focusedIdx = -1;
let filtered = [...options]; let filtered = [...options];
// ARIA combobox wiring
const dropId = dropdown.id || ('lt-cb-drop-' + Math.random().toString(36).slice(2));
dropdown.id = dropId;
dropdown.setAttribute('role', 'listbox');
inputEl.setAttribute('role', 'combobox');
inputEl.setAttribute('aria-expanded', 'false');
inputEl.setAttribute('aria-controls', dropId);
inputEl.setAttribute('aria-autocomplete', 'list');
function _renderTags() { function _renderTags() {
wrap.querySelectorAll('.lt-combobox-tag').forEach(t => t.remove()); wrap.querySelectorAll('.lt-combobox-tag').forEach(t => t.remove());
selected.forEach(v => { selected.forEach(v => {
@@ -1816,7 +1868,8 @@
el.className = 'lt-combobox-option' + (selected.includes(opt.value) ? ' is-selected' : ''); el.className = 'lt-combobox-option' + (selected.includes(opt.value) ? ' is-selected' : '');
el.setAttribute('role', 'option'); el.setAttribute('role', 'option');
el.setAttribute('data-value', opt.value); el.setAttribute('data-value', opt.value);
const hl = q ? opt.label.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '<mark>$1</mark>') : escHtml(opt.label); const safeLabel = escHtml(opt.label);
const hl = q ? safeLabel.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '<mark>$1</mark>') : safeLabel;
el.innerHTML = `${opt.icon ? `<span class="icon">${escHtml(opt.icon)}</span>` : ''}<span>${hl}</span>`; el.innerHTML = `${opt.icon ? `<span class="icon">${escHtml(opt.icon)}</span>` : ''}<span>${hl}</span>`;
el.addEventListener('mousedown', e => { e.preventDefault(); _toggle(opt.value); }); el.addEventListener('mousedown', e => { e.preventDefault(); _toggle(opt.value); });
dropdown.appendChild(el); dropdown.appendChild(el);
@@ -1843,13 +1896,17 @@
items[focusedIdx].scrollIntoView({ block: 'nearest' }); items[focusedIdx].scrollIntoView({ block: 'nearest' });
} }
inputEl.addEventListener('input', () => { dropdown.classList.add('is-open'); _renderDropdown(inputEl.value); }); function _setOpen(open) {
inputEl.addEventListener('focus', () => { dropdown.classList.add('is-open'); _renderDropdown(inputEl.value); }); dropdown.classList.toggle('is-open', open);
inputEl.setAttribute('aria-expanded', open ? 'true' : 'false');
}
inputEl.addEventListener('input', () => { _setOpen(true); _renderDropdown(inputEl.value); });
inputEl.addEventListener('focus', () => { _setOpen(true); _renderDropdown(inputEl.value); });
inputEl.addEventListener('keydown', e => { inputEl.addEventListener('keydown', e => {
if (e.key === 'ArrowDown') { e.preventDefault(); _moveFocus(1); } if (e.key === 'ArrowDown') { e.preventDefault(); _moveFocus(1); }
if (e.key === 'ArrowUp') { e.preventDefault(); _moveFocus(-1); } if (e.key === 'ArrowUp') { e.preventDefault(); _moveFocus(-1); }
if (e.key === 'Enter') { e.preventDefault(); if (focusedIdx >= 0 && filtered[focusedIdx]) _toggle(filtered[focusedIdx].value); } if (e.key === 'Enter') { e.preventDefault(); if (focusedIdx >= 0 && filtered[focusedIdx]) _toggle(filtered[focusedIdx].value); }
if (e.key === 'Escape') { dropdown.classList.remove('is-open'); } if (e.key === 'Escape') { _setOpen(false); }
if (e.key === 'Backspace' && !inputEl.value && selected.length) { _toggle(selected[selected.length - 1]); } if (e.key === 'Backspace' && !inputEl.value && selected.length) { _toggle(selected[selected.length - 1]); }
}); });
inputWrap.addEventListener('mousedown', e => { inputWrap.addEventListener('mousedown', e => {
@@ -1857,7 +1914,7 @@
if (rmBtn) { e.preventDefault(); _toggle(rmBtn.dataset.value); return; } if (rmBtn) { e.preventDefault(); _toggle(rmBtn.dataset.value); return; }
inputEl.focus(); inputEl.focus();
}); });
document.addEventListener('click', e => { if (!wrap.contains(e.target)) dropdown.classList.remove('is-open'); }); document.addEventListener('click', e => { if (!wrap.contains(e.target)) _setOpen(false); });
_renderTags(); _renderTags();
_renderDropdown(''); _renderDropdown('');
@@ -1899,7 +1956,8 @@
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'lt-typeahead-item'; el.className = 'lt-typeahead-item';
el.setAttribute('role', 'option'); el.setAttribute('role', 'option');
const hl = item.label.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '<mark>$1</mark>'); const safeItemLabel = escHtml(item.label);
const hl = safeItemLabel.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '<mark>$1</mark>');
el.innerHTML = `${item.icon ? `<span class="icon">${escHtml(item.icon)}</span>` : ''}<span>${hl}</span>${item.meta ? `<span style="margin-left:auto;color:var(--text-muted);font-size:0.68rem">${escHtml(item.meta)}</span>` : ''}`; el.innerHTML = `${item.icon ? `<span class="icon">${escHtml(item.icon)}</span>` : ''}<span>${hl}</span>${item.meta ? `<span style="margin-left:auto;color:var(--text-muted);font-size:0.68rem">${escHtml(item.meta)}</span>` : ''}`;
el.addEventListener('mousedown', e => { e.preventDefault(); _select(item); }); el.addEventListener('mousedown', e => { e.preventDefault(); _select(item); });
dropdown.appendChild(el); dropdown.appendChild(el);
@@ -2119,8 +2177,9 @@
const dist = el.scrollHeight - el.scrollTop - el.clientHeight; const dist = el.scrollHeight - el.scrollTop - el.clientHeight;
if (dist < threshold) _load(); if (dist < threshold) _load();
} }
scrollRoot.addEventListener('scroll', throttle(_onScroll, 150), { passive: true }); const _onScrollThrottled = throttle(_onScroll, 150);
return { reset() { _done = false; _loading = false; }, stop() { scrollRoot.removeEventListener('scroll', _onScroll); } }; scrollRoot.addEventListener('scroll', _onScrollThrottled, { passive: true });
return { reset() { _done = false; _loading = false; }, stop() { scrollRoot.removeEventListener('scroll', _onScrollThrottled); } };
} }
}, },
}; };
@@ -2175,7 +2234,11 @@
if (first) setTimeout(() => first.focus(), 60); if (first) setTimeout(() => first.focus(), 60);
} }
let _wizBusy = false;
async function _next() { async function _next() {
if (_wizBusy) return;
_wizBusy = true;
try {
if (validate) { if (validate) {
const ok = await validate(current + 1, _getStepData(current)); const ok = await validate(current + 1, _getStepData(current));
if (!ok) { if (!ok) {
@@ -2185,6 +2248,9 @@
} }
Object.assign(formData, _getStepData(current)); Object.assign(formData, _getStepData(current));
if (current < total - 1) { current++; _show(current); } if (current < total - 1) { current++; _show(current); }
} finally {
_wizBusy = false;
}
} }
function _prev() { function _prev() {
@@ -2671,7 +2737,10 @@
alerts: bool, clipboard: bool, sidebar: bool, submenus: bool } alerts: bool, clipboard: bool, sidebar: bool, submenus: bool }
Individual modules can still be called manually. Individual modules can still be called manually.
================================================================ */ ================================================================ */
let _ltInitialized = false;
function ltInit(opts) { function ltInit(opts) {
if (_ltInitialized) return; // Guard: safe to call multiple times
_ltInitialized = true;
const o = Object.assign({ const o = Object.assign({
boot: true, boot: true,
bootName: null, bootName: null,