diff --git a/base.css b/base.css
index d04358f..9e07e93 100644
--- a/base.css
+++ b/base.css
@@ -4168,6 +4168,326 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
.lt-ws-status[data-state="disconnected"] .lt-dot { background: var(--accent-red); }
+/* ----------------------------------------------------------------
+ 69. INFINITE SCROLL SENTINEL / LOADING
+ ---------------------------------------------------------------- */
+.lt-infinite-sentinel { height: 1px; width: 100%; }
+.lt-infinite-loading {
+ display: flex;
+ justify-content: center;
+ padding: 1.5rem;
+}
+
+
+/* ----------------------------------------------------------------
+ 70. WIZARD / MULTI-STEP FORM
+ ---------------------------------------------------------------- */
+/* Step container — hide non-active steps */
+[data-wizard-step] { display: none; }
+[data-wizard-step].is-active { display: block; }
+
+/* Progress indicator bar */
+.lt-wizard-steps {
+ display: flex;
+ align-items: center;
+ gap: 0;
+ margin-bottom: 1.5rem;
+ overflow-x: auto;
+ padding-bottom: 2px;
+}
+.lt-wizard-step {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.3rem;
+ flex: 1;
+ min-width: 60px;
+ position: relative;
+ cursor: default;
+}
+/* Connector line between steps */
+.lt-wizard-step::before {
+ content: '';
+ position: absolute;
+ top: 14px;
+ left: calc(-50% + 14px);
+ right: calc(50% + 14px);
+ height: 1px;
+ background: var(--border-dim);
+ z-index: 0;
+}
+.lt-wizard-step:first-child::before { display: none; }
+.lt-wizard-step.is-complete::before { background: var(--accent-cyan); }
+/* Step number circle */
+.lt-wizard-num {
+ width: 28px; height: 28px;
+ border-radius: 50%;
+ background: var(--bg-secondary);
+ border: 1.5px solid var(--border-color);
+ color: var(--text-muted);
+ font-size: 0.72rem;
+ font-weight: 700;
+ display: flex; align-items: center; justify-content: center;
+ position: relative;
+ z-index: 1;
+ transition: all 0.2s;
+}
+.lt-wizard-step.is-active .lt-wizard-num {
+ background: var(--accent-orange-dim);
+ border-color: var(--accent-orange);
+ color: var(--accent-orange);
+ box-shadow: var(--box-glow-orange);
+}
+.lt-wizard-step.is-complete .lt-wizard-num {
+ background: var(--accent-cyan-dim);
+ border-color: var(--accent-cyan);
+ color: var(--accent-cyan);
+}
+.lt-wizard-step.is-complete .lt-wizard-num::after {
+ content: '✓';
+ position: absolute;
+}
+.lt-wizard-step.is-error .lt-wizard-num {
+ background: var(--accent-red-dim);
+ border-color: var(--accent-red);
+ color: var(--accent-red);
+}
+.lt-wizard-label {
+ font-size: 0.62rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--text-muted);
+ white-space: nowrap;
+ text-align: center;
+}
+.lt-wizard-step.is-active .lt-wizard-label { color: var(--accent-orange); }
+.lt-wizard-step.is-complete .lt-wizard-label { color: var(--text-secondary); }
+/* Counter badge */
+.lt-wizard-counter {
+ font-size: 0.68rem;
+ color: var(--text-muted);
+}
+.lt-wizard-counter strong { color: var(--accent-cyan); }
+/* Nav footer */
+.lt-wizard-nav {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: 1.5rem;
+ padding-top: 1rem;
+ border-top: 1px solid var(--border-dim);
+ gap: 0.75rem;
+}
+
+
+/* ----------------------------------------------------------------
+ 71. SORTABLE LIST
+ ---------------------------------------------------------------- */
+[data-sortable-item] { transition: opacity 0.15s; }
+[data-sortable-item].is-dragging {
+ opacity: 0.35;
+ cursor: grabbing;
+}
+.lt-sortable-placeholder {
+ background: var(--accent-cyan-dim);
+ border: 1px dashed var(--accent-cyan-border);
+ border-radius: 2px;
+ pointer-events: none;
+}
+
+
+/* ----------------------------------------------------------------
+ 72. COUNTDOWN / TIMER
+ ---------------------------------------------------------------- */
+.lt-countdown {
+ font-variant-numeric: tabular-nums;
+ font-feature-settings: "tnum";
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: var(--accent-cyan);
+ letter-spacing: 0.05em;
+}
+.lt-countdown.lt-text-red { text-shadow: var(--glow-red); }
+.lt-countdown.lt-text-cyan { text-shadow: var(--glow-cyan); }
+/* SLA urgency animation */
+.lt-countdown-urgent {
+ animation: lt-countdown-blink 1s step-end infinite;
+}
+@keyframes lt-countdown-blink {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.4; }
+}
+@media (prefers-reduced-motion: reduce) {
+ .lt-countdown-urgent { animation: none; }
+}
+
+
+/* ----------------------------------------------------------------
+ 73. IMAGE LIGHTBOX
+ ---------------------------------------------------------------- */
+.lt-lightbox-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(3,5,8,0.96);
+ z-index: calc(var(--z-modal) + 10); /* above everything */
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.2s ease;
+}
+.lt-lightbox-overlay.is-open {
+ opacity: 1;
+ pointer-events: auto;
+}
+.lt-lightbox-img-wrap {
+ max-width: 90vw;
+ max-height: 85vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.lt-lightbox-img {
+ max-width: 100%;
+ max-height: 85vh;
+ object-fit: contain;
+ box-shadow: 0 0 60px rgba(0,212,255,0.12);
+ display: block;
+}
+.lt-lightbox-close,
+.lt-lightbox-prev,
+.lt-lightbox-next {
+ position: fixed;
+ background: rgba(10,14,23,0.75);
+ border: 1px solid var(--border-color);
+ color: var(--text-secondary);
+ font-size: 1.5rem;
+ cursor: pointer;
+ transition: var(--transition-fast);
+ display: flex; align-items: center; justify-content: center;
+ border-radius: 2px;
+}
+.lt-lightbox-close { top: 1rem; right: 1rem; width: 40px; height: 40px; }
+.lt-lightbox-prev { left: 1rem; top: 50%; transform: translateY(-50%); width: 40px; height: 60px; }
+.lt-lightbox-next { right: 1rem; top: 50%; transform: translateY(-50%); width: 40px; height: 60px; }
+.lt-lightbox-close:hover,
+.lt-lightbox-prev:hover,
+.lt-lightbox-next:hover { color: var(--accent-cyan); border-color: var(--accent-cyan-border); box-shadow: var(--box-glow-cyan); }
+.lt-lightbox-caption {
+ position: fixed;
+ bottom: 3rem;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: 0.78rem;
+ color: var(--text-secondary);
+ max-width: 60vw;
+ text-align: center;
+}
+.lt-lightbox-counter {
+ position: fixed;
+ bottom: 1rem;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: 0.68rem;
+ color: var(--text-muted);
+ letter-spacing: 0.1em;
+}
+@media (pointer: coarse) {
+ .lt-lightbox-prev { width: 56px; height: 80px; }
+ .lt-lightbox-next { width: 56px; height: 80px; }
+ .lt-lightbox-close { width: 48px; height: 48px; }
+}
+
+
+/* ----------------------------------------------------------------
+ 74. SIDEBAR SUBMENUS
+ ---------------------------------------------------------------- */
+.lt-sidebar-group {
+ margin-bottom: 0.25rem;
+}
+.lt-sidebar-group-label {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.35rem 0.75rem;
+ font-size: 0.65rem;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--text-muted);
+ cursor: pointer;
+ user-select: none;
+ transition: color 0.15s;
+ border-radius: 2px;
+}
+.lt-sidebar-group-label:hover { color: var(--text-secondary); }
+.lt-sidebar-group-label .chevron {
+ font-size: 0.5rem;
+ transition: transform 0.2s ease;
+ opacity: 0.6;
+}
+.lt-sidebar-group.is-open .lt-sidebar-group-label .chevron { transform: rotate(90deg); }
+/* Submenu items */
+.lt-sidebar-submenu {
+ display: none;
+ flex-direction: column;
+ padding-left: 0.75rem;
+ border-left: 1px solid var(--border-dim);
+ margin-left: 0.75rem;
+ margin-top: 2px;
+ margin-bottom: 4px;
+}
+.lt-sidebar-group.is-open .lt-sidebar-submenu { display: flex; }
+.lt-sidebar-sub-link {
+ padding: 0.3rem 0.5rem;
+ font-size: 0.72rem;
+ color: var(--text-muted);
+ text-decoration: none;
+ border-radius: 2px;
+ transition: var(--transition-fast);
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ white-space: nowrap;
+}
+.lt-sidebar-sub-link:hover { color: var(--accent-cyan); background: var(--accent-cyan-dim); }
+.lt-sidebar-sub-link.active,
+.lt-sidebar-sub-link[aria-current="page"] {
+ color: var(--accent-orange);
+ background: var(--accent-orange-dim);
+}
+@media (pointer: coarse) {
+ .lt-sidebar-group-label { padding: 0.5rem 0.75rem; }
+ .lt-sidebar-sub-link { padding: 0.4rem 0.5rem; min-height: 36px; }
+}
+
+
+/* ----------------------------------------------------------------
+ 75. MARKDOWN OUTPUT STYLING
+ ---------------------------------------------------------------- */
+.lt-markdown h1 { font-size: 1.25rem; color: var(--accent-cyan); border-bottom: 1px solid var(--border-dim); padding-bottom: 0.3rem; margin: 1rem 0 0.5rem; }
+.lt-markdown h2 { font-size: 1.05rem; color: var(--text-primary); margin: 0.9rem 0 0.4rem; }
+.lt-markdown h3 { font-size: 0.9rem; color: var(--text-secondary); margin: 0.75rem 0 0.35rem; }
+.lt-markdown h4, .lt-markdown h5, .lt-markdown h6 { font-size: 0.8rem; color: var(--text-muted); margin: 0.5rem 0 0.25rem; }
+.lt-markdown p { font-size: 0.82rem; line-height: 1.7; color: var(--text-secondary); margin: 0.5rem 0; }
+.lt-markdown ul, .lt-markdown ol { padding-left: 1.25rem; margin: 0.5rem 0; }
+.lt-markdown li { font-size: 0.8rem; color: var(--text-secondary); line-height: 1.6; margin-bottom: 0.2rem; }
+.lt-markdown ul li::marker { color: var(--accent-cyan); }
+.lt-markdown ol li::marker { color: var(--accent-orange); }
+.lt-markdown code { font-family: var(--font-mono); font-size: 0.78rem; color: var(--accent-green); background: var(--bg-secondary); padding: 1px 5px; border-radius: 2px; }
+.lt-markdown pre { margin: 0.75rem 0; overflow-x: auto; }
+.lt-markdown blockquote { border-left: 3px solid var(--accent-cyan-border); padding: 0.25rem 0.75rem; margin: 0.5rem 0; background: var(--accent-cyan-dim); color: var(--text-muted); font-style: italic; }
+.lt-markdown hr { border: none; border-top: 1px solid var(--border-dim); margin: 1rem 0; }
+.lt-markdown a { color: var(--accent-cyan); text-decoration: none; }
+.lt-markdown a:hover { text-decoration: underline; }
+.lt-markdown strong { color: var(--text-primary); }
+.lt-markdown img { max-width: 100%; border: 1px solid var(--border-dim); }
+.lt-markdown table { width: 100%; border-collapse: collapse; font-size: 0.78rem; margin: 0.75rem 0; }
+.lt-markdown th { background: var(--bg-secondary); color: var(--accent-cyan); padding: 0.4rem 0.6rem; border: 1px solid var(--border-dim); text-align: left; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; }
+.lt-markdown td { padding: 0.35rem 0.6rem; border: 1px solid var(--border-dim); color: var(--text-secondary); }
+.lt-markdown tr:hover td { background: var(--bg-secondary); }
+
+
/* ----------------------------------------------------------------
68. PRINT ENHANCEMENTS
(Extends section 25)
diff --git a/base.html b/base.html
index d290243..6eccc38 100644
--- a/base.html
+++ b/base.html
@@ -1256,6 +1256,191 @@
+
+
+
+
+
+
+
+
+
+
Step 1 of 3
+
+
+
+
+
+
+
+
+
+
✅
Review & Submit
Check the details above and click Submit when ready.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Drag items to reorder. Uses native HTML5 drag API with pointer-events fallback.
+
+ - ⠿ P1 — Storage link-down
+ - ⠿ P2 — Switch port flapping
+ - ⠿ P3 — SFP+ replacement
+ - ⠿ P4 — SSL cert renewal
+
+
Order: p1, p2, p3, p4
+
+
+
API:
+
const s = lt.sortable.init(listEl, {
+ onSort: (items) => console.log(s.getOrder())
+});
+
+
+
+
+
+
+
+
+
+
+
+
SLA Countdown
+
--:--:--
+
Goes urgent (red) at <5 min
+
+
+
Stopwatch
+
00:00:00
+
+
+
+
+
+
+
API
+
lt.timer.countdown(el, date, {
+ urgent: 300,
+ onExpire: () => {}
+});
+lt.timer.stopwatch(el);
+
+
+
+
+
+
+
+
+
+
Click any image to open full-screen viewer. Keyboard: ←/→ navigate, Esc closes.
+
+
lt.lightbox.init('.lt-lightbox-demo', { caption: 'alt', loop: true })
+
+
+
+
+
+
+
+
+
+
+
+
+
Source
+
# Incident Report
+**Severity**: P1 Critical
+
+## Summary
+Storage array link-down on `compute-storage-01`.
+
+- Affected: 3 production services
+- Duration: 2h 33m
+- Root cause: NIC hardware failure
+
+> Resolved. Monitoring continues.
+
+[View Ticket](#001)
+
+
+
+
Built-in micro-renderer (no deps). Drops in window.marked or window.markdownit automatically if present.
+
+
+
@@ -1490,6 +1675,48 @@
// Demo notification badge initial count
lt.notif.set('#lt-notif-bell', 3);
+ // Wizard demo
+ lt.wizard.init(document.getElementById('demo-wizard'), {
+ onComplete: data => lt.toast.success('Ticket submitted: ' + (data.title || 'untitled')),
+ validate: (step, data) => {
+ if (step === 1 && !data.title?.trim()) { lt.toast.error('Title is required'); return false; }
+ return true;
+ },
+ });
+
+ // Sortable demo
+ const sortableList = lt.sortable.init(document.getElementById('demo-sortable'), {
+ onSort: (items) => {
+ document.getElementById('demo-sort-order').textContent = 'Order: ' + items.map(el => el.dataset.id).join(', ');
+ },
+ });
+
+ // Countdown demo — SLA expires 2 hours from now
+ const slaTarget = new Date(Date.now() + 2 * 60 * 60 * 1000);
+ lt.timer.countdown(document.getElementById('demo-countdown'), slaTarget, {
+ urgent: 300,
+ urgentClass: 'lt-text-red lt-countdown-urgent',
+ onExpire: () => lt.toast.error('SLA BREACHED'),
+ });
+
+ // Stopwatch demo
+ const sw = lt.timer.stopwatch(document.getElementById('demo-stopwatch'));
+ let swRunning = true;
+ document.getElementById('sw-pause').addEventListener('click', function() {
+ if (swRunning) { sw.pause(); this.textContent = 'Resume'; } else { sw.resume(); this.textContent = 'Pause'; }
+ swRunning = !swRunning;
+ });
+ document.getElementById('sw-reset').addEventListener('click', () => { sw.reset(); swRunning = true; document.getElementById('sw-pause').textContent = 'Pause'; });
+
+ // Lightbox demo
+ lt.lightbox.init('.lt-lightbox-demo');
+
+ // Markdown demo
+ lt.markdown.init('#demo-markdown');
+
+ // Sidebar submenus (re-init for demo sidebar)
+ lt.sidebarSubmenus.init(document.querySelector('.lt-section-body'));
+
// Tab bar switching
document.querySelectorAll('.lt-tab-bar').forEach(bar => {
bar.addEventListener('click', e => {
diff --git a/base.js b/base.js
index 68125b1..7fed36f 100644
--- a/base.js
+++ b/base.js
@@ -1257,6 +1257,7 @@
observeLazy('[data-lazy]');
/* v1.3 */
initMobileNav();
+ initSidebarSubmenus();
/* Boot */
const bootEl = document.getElementById('lt-boot');
if (bootEl) runBoot(bootEl.dataset.appName || document.title);
@@ -2070,6 +2071,578 @@
}
});
+ /* ================================================================
+ MODULE 48 — SIDEBAR SUBMENUS
+ Auto-inits .lt-sidebar-group elements.
+ Click label → toggle .is-open + animate submenu.
+ ================================================================ */
+ function initSidebarSubmenus(root) {
+ const container = root || document;
+ container.querySelectorAll('.lt-sidebar-group').forEach(group => {
+ if (group._sbInit) return;
+ group._sbInit = true;
+ const label = group.querySelector('.lt-sidebar-group-label');
+ if (!label) return;
+ label.addEventListener('click', () => group.classList.toggle('is-open'));
+ // Open group if it contains the active link
+ if (group.querySelector('.lt-sidebar-sub-link.active, .lt-sidebar-sub-link[aria-current="page"]')) {
+ group.classList.add('is-open');
+ }
+ });
+ }
+
+ /* ================================================================
+ MODULE 49 — INFINITE SCROLL
+ lt.infiniteScroll.init(containerEl, loadFn, opts)
+ loadFn: async fn() → { items: [], done: bool }
+ opts: { threshold (px from bottom), loadingClass, sentinelClass }
+ ================================================================ */
+ const infiniteScroll = {
+ init(container, loadFn, opts = {}) {
+ const { threshold = 200, onEmpty = null } = opts;
+ let _loading = false;
+ let _done = false;
+
+ // Sentinel element at the bottom of the container
+ const sentinel = document.createElement('div');
+ sentinel.className = 'lt-infinite-sentinel';
+ sentinel.setAttribute('aria-hidden', 'true');
+ container.appendChild(sentinel);
+
+ // Loading indicator
+ const loadingEl = document.createElement('div');
+ loadingEl.className = 'lt-infinite-loading lt-loading lt-hidden';
+ loadingEl.setAttribute('aria-live', 'polite');
+ loadingEl.setAttribute('aria-label', 'Loading more items');
+ container.appendChild(loadingEl);
+
+ async function _load() {
+ if (_loading || _done) return;
+ _loading = true;
+ loadingEl.classList.remove('lt-hidden');
+ try {
+ const result = await loadFn();
+ if (result && result.done) {
+ _done = true;
+ sentinel.remove();
+ loadingEl.remove();
+ if (onEmpty) onEmpty();
+ }
+ } catch (e) {
+ console.error('[lt.infiniteScroll]', e);
+ } finally {
+ _loading = false;
+ loadingEl.classList.add('lt-hidden');
+ }
+ }
+
+ // Use IntersectionObserver if available, else scroll listener
+ if (global.IntersectionObserver) {
+ const io = new IntersectionObserver(entries => {
+ if (entries[0].isIntersecting) _load();
+ }, { rootMargin: `0px 0px ${threshold}px 0px` });
+ io.observe(sentinel);
+ return { reset() { _done = false; _loading = false; }, stop() { io.disconnect(); } };
+ } else {
+ const scrollRoot = container === document.body ? window : container;
+ function _onScroll() {
+ const el = container === document.body ? document.documentElement : container;
+ const dist = el.scrollHeight - el.scrollTop - el.clientHeight;
+ if (dist < threshold) _load();
+ }
+ scrollRoot.addEventListener('scroll', throttle(_onScroll, 150), { passive: true });
+ return { reset() { _done = false; _loading = false; }, stop() { scrollRoot.removeEventListener('scroll', _onScroll); } };
+ }
+ },
+ };
+
+ /* ================================================================
+ MODULE 49 — WIZARD / MULTI-STEP FORM
+ lt.wizard.init(containerEl, opts)
+ opts: { onStep(n, total), onComplete(data), validate(n) }
+ HTML: [data-wizard-step="1"] ... [data-wizard-nav]
+ ================================================================ */
+ const wizard = {
+ init(container, opts = {}) {
+ const { onStep = null, onComplete = null, validate = null } = opts;
+ const steps = Array.from(container.querySelectorAll('[data-wizard-step]'));
+ const total = steps.length;
+ let current = 0;
+ const formData = {};
+
+ function _getStepData(idx) {
+ const step = steps[idx];
+ const data = {};
+ step.querySelectorAll('input, select, textarea').forEach(el => {
+ if (el.name) data[el.name] = el.type === 'checkbox' ? el.checked : el.value;
+ });
+ return data;
+ }
+
+ function _show(idx) {
+ steps.forEach((s, i) => {
+ s.classList.toggle('is-active', i === idx);
+ s.setAttribute('aria-hidden', i !== idx ? 'true' : 'false');
+ });
+ // Update step indicators
+ container.querySelectorAll('[data-wizard-indicator]').forEach((ind, i) => {
+ ind.classList.toggle('is-active', i === idx);
+ ind.classList.toggle('is-complete', i < idx);
+ ind.classList.remove('is-error');
+ });
+ // Update nav buttons
+ const prevBtn = container.querySelector('[data-wizard-prev]');
+ const nextBtn = container.querySelector('[data-wizard-next]');
+ const doneBtn = container.querySelector('[data-wizard-done]');
+ if (prevBtn) prevBtn.disabled = idx === 0;
+ if (nextBtn) nextBtn.style.display = idx < total - 1 ? '' : 'none';
+ if (doneBtn) doneBtn.style.display = idx === total - 1 ? '' : 'none';
+ // Update step counter
+ container.querySelectorAll('[data-wizard-current]').forEach(el => el.textContent = idx + 1);
+ container.querySelectorAll('[data-wizard-total]').forEach(el => el.textContent = total);
+ if (onStep) onStep(idx + 1, total, formData);
+ // Focus first input in step
+ const first = steps[idx].querySelector('input, select, textarea, button');
+ if (first) setTimeout(() => first.focus(), 60);
+ }
+
+ async function _next() {
+ if (validate) {
+ const ok = await validate(current + 1, _getStepData(current));
+ if (!ok) {
+ container.querySelectorAll('[data-wizard-indicator]')[current]?.classList.add('is-error');
+ return;
+ }
+ }
+ Object.assign(formData, _getStepData(current));
+ if (current < total - 1) { current++; _show(current); }
+ }
+
+ function _prev() {
+ if (current > 0) { current--; _show(current); }
+ }
+
+ function _done() {
+ Object.assign(formData, _getStepData(current));
+ if (onComplete) onComplete({ ...formData });
+ }
+
+ function _goTo(n) { // 1-based
+ const idx = Math.max(0, Math.min(n - 1, total - 1));
+ current = idx; _show(current);
+ }
+
+ // Wire up nav buttons
+ container.querySelector('[data-wizard-next]')?.addEventListener('click', _next);
+ container.querySelector('[data-wizard-prev]')?.addEventListener('click', _prev);
+ container.querySelector('[data-wizard-done]')?.addEventListener('click', _done);
+
+ _show(0);
+ return { next: _next, prev: _prev, goTo: _goTo, getData: () => ({ ...formData }), total };
+ },
+ };
+
+ /* ================================================================
+ MODULE 50 — SORTABLE (drag-to-reorder lists/kanban)
+ lt.sortable.init(listEl, opts)
+ opts: { handle (selector), onSort(newOrder, movedEl), group }
+ Returns draggable list; emits 'sortable:change' on bus
+ ================================================================ */
+ const sortable = {
+ init(list, opts = {}) {
+ const { handle = null, onSort = null, group = null, animation = 200 } = opts;
+ list.setAttribute('data-sortable-group', group || '');
+ let _dragging = null, _placeholder = null, _startIdx = -1;
+
+ function _idx(el) { return Array.from(list.children).indexOf(el); }
+
+ function _makePlaceholder(el) {
+ const ph = document.createElement(el.tagName);
+ ph.className = 'lt-sortable-placeholder';
+ ph.style.height = el.offsetHeight + 'px';
+ ph.style.width = el.offsetWidth + 'px';
+ return ph;
+ }
+
+ function _getItems() { return Array.from(list.querySelectorAll(':scope > [data-sortable-item]')); }
+
+ // Mark all children as sortable items
+ Array.from(list.children).forEach(child => {
+ child.setAttribute('data-sortable-item', '');
+ child.setAttribute('draggable', handle ? 'false' : 'true');
+ if (handle) {
+ const h = child.querySelector(handle);
+ if (h) { h.setAttribute('draggable', 'true'); h.style.cursor = 'grab'; }
+ } else { child.style.cursor = 'grab'; }
+ });
+
+ list.addEventListener('dragstart', e => {
+ const item = e.target.closest('[data-sortable-item]');
+ if (!item || !list.contains(item)) return;
+ _dragging = item;
+ _startIdx = _idx(item);
+ _placeholder = _makePlaceholder(item);
+ requestAnimationFrame(() => { item.classList.add('is-dragging'); });
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData('text/plain', ''); // Firefox compat
+ });
+
+ list.addEventListener('dragend', () => {
+ if (!_dragging) return;
+ _dragging.classList.remove('is-dragging');
+ _dragging.style.opacity = '';
+ if (_placeholder && _placeholder.parentNode) {
+ _placeholder.parentNode.insertBefore(_dragging, _placeholder);
+ _placeholder.remove();
+ }
+ const endIdx = _idx(_dragging);
+ if (endIdx !== _startIdx && onSort) onSort(_getItems(), _dragging);
+ bus.emit('sortable:change', { list, items: _getItems(), moved: _dragging });
+ _dragging = null; _placeholder = null;
+ });
+
+ list.addEventListener('dragover', e => {
+ e.preventDefault(); e.dataTransfer.dropEffect = 'move';
+ const over = e.target.closest('[data-sortable-item]');
+ if (!over || over === _dragging || !list.contains(over)) return;
+ if (!_placeholder) return;
+ const rect = over.getBoundingClientRect();
+ const mid = rect.top + rect.height / 2;
+ over.parentNode.insertBefore(_placeholder, e.clientY < mid ? over : over.nextSibling);
+ });
+
+ list.addEventListener('dragenter', e => {
+ const over = e.target.closest('[data-sortable-item]');
+ if (over && over !== _dragging && list.contains(over) && !_placeholder) {
+ _placeholder = _makePlaceholder(_dragging);
+ }
+ });
+
+ list.addEventListener('drop', e => { e.preventDefault(); });
+
+ return {
+ refresh() {
+ Array.from(list.children).forEach(child => {
+ if (!child.hasAttribute('data-sortable-item')) {
+ child.setAttribute('data-sortable-item', '');
+ child.setAttribute('draggable', handle ? 'false' : 'true');
+ if (!handle) child.style.cursor = 'grab';
+ }
+ });
+ },
+ getOrder: () => _getItems().map(el => el.dataset.id || el.textContent.trim()),
+ };
+ },
+ };
+
+ /* ================================================================
+ MODULE 51 — COUNTDOWN / TIMER
+ lt.timer.countdown(el, targetDate, opts)
+ lt.timer.stopwatch(el, opts)
+ el = DOM element or selector; updates .textContent
+ opts: { onExpire, format, urgent (seconds), urgentClass }
+ ================================================================ */
+ const timer = {
+ countdown(el, target, opts = {}) {
+ const dom = typeof el === 'string' ? document.querySelector(el) : el;
+ if (!dom) return;
+ const { onExpire = null, urgent = 300, urgentClass = 'lt-text-red' } = opts;
+ const end = target instanceof Date ? target : new Date(target);
+
+ function _tick() {
+ const diff = Math.floor((end - Date.now()) / 1000);
+ if (diff <= 0) {
+ dom.textContent = '00:00:00';
+ dom.classList.add(urgentClass);
+ if (onExpire) onExpire();
+ clearInterval(handle);
+ return;
+ }
+ if (diff <= urgent) dom.classList.add(urgentClass);
+ const h = Math.floor(diff / 3600), m = Math.floor((diff % 3600) / 60), s = diff % 60;
+ dom.textContent = [h, m, s].map(n => String(n).padStart(2, '0')).join(':');
+ }
+ _tick();
+ const handle = setInterval(_tick, 1000);
+ return { stop: () => clearInterval(handle) };
+ },
+
+ stopwatch(el, opts = {}) {
+ const dom = typeof el === 'string' ? document.querySelector(el) : el;
+ if (!dom) return;
+ const { onTick = null } = opts;
+ let start = Date.now(), paused = false, offset = 0;
+
+ function _tick() {
+ if (paused) return;
+ const elapsed = Math.floor((Date.now() - start + offset) / 1000);
+ const h = Math.floor(elapsed / 3600), m = Math.floor((elapsed % 3600) / 60), s = elapsed % 60;
+ dom.textContent = [h, m, s].map(n => String(n).padStart(2, '0')).join(':');
+ if (onTick) onTick(elapsed);
+ }
+ const handle = setInterval(_tick, 1000);
+ _tick();
+ return {
+ pause() { paused = true; offset += Date.now() - start; },
+ resume() { paused = false; start = Date.now(); },
+ reset() { offset = 0; start = Date.now(); _tick(); },
+ stop() { clearInterval(handle); },
+ elapsed: () => Math.floor((Date.now() - start + offset) / 1000),
+ };
+ },
+ };
+
+ /* ================================================================
+ MODULE 52 — IMAGE LIGHTBOX
+ lt.lightbox.init(selector, opts)
+ Clicking any matched image opens a full-screen overlay with
+ prev/next, keyboard nav, zoom.
+ opts: { caption (fn|'alt'|'title'), loop }
+ ================================================================ */
+ const lightbox = {
+ init(selector, opts = {}) {
+ const { caption = 'alt', loop = true } = opts;
+ let _images = [], _current = 0, _overlay = null;
+
+ function _getCaption(img) {
+ if (typeof caption === 'function') return caption(img);
+ return img.getAttribute(caption) || '';
+ }
+
+ function _buildOverlay() {
+ if (_overlay) return;
+ _overlay = document.createElement('div');
+ _overlay.className = 'lt-lightbox-overlay';
+ _overlay.setAttribute('role', 'dialog');
+ _overlay.setAttribute('aria-modal', 'true');
+ _overlay.setAttribute('aria-label', 'Image viewer');
+ _overlay.innerHTML = `
+
+
+
+
+
![]()
+
+
+
+ `;
+ document.body.appendChild(_overlay);
+
+ _overlay.querySelector('.lt-lightbox-close').addEventListener('click', lightbox.close);
+ _overlay.querySelector('.lt-lightbox-prev').addEventListener('click', () => lightbox.prev());
+ _overlay.querySelector('.lt-lightbox-next').addEventListener('click', () => lightbox.next());
+ _overlay.addEventListener('click', e => { if (e.target === _overlay) lightbox.close(); });
+ document.addEventListener('keydown', _lbKey);
+ }
+
+ function _lbKey(e) {
+ if (!_overlay || !_overlay.classList.contains('is-open')) return;
+ if (e.key === 'Escape') lightbox.close();
+ if (e.key === 'ArrowLeft') lightbox.prev();
+ if (e.key === 'ArrowRight') lightbox.next();
+ }
+
+ function _show(idx) {
+ if (!_overlay) _buildOverlay();
+ _current = idx;
+ const img = _images[idx];
+ const el = _overlay.querySelector('.lt-lightbox-img');
+ el.src = img.src; el.alt = img.alt || '';
+ _overlay.querySelector('.lt-lightbox-caption').textContent = _getCaption(img);
+ _overlay.querySelector('.lt-lightbox-counter').textContent = `${idx + 1} / ${_images.length}`;
+ // Hide prev/next when single image or at edges
+ _overlay.querySelector('.lt-lightbox-prev').style.display = (loop || idx > 0) && _images.length > 1 ? '' : 'none';
+ _overlay.querySelector('.lt-lightbox-next').style.display = (loop || idx < _images.length - 1) && _images.length > 1 ? '' : 'none';
+ _overlay.classList.add('is-open');
+ _lockScroll();
+ setTimeout(() => el.focus?.(), 50);
+ }
+
+ function _collect() {
+ _images = Array.from(document.querySelectorAll(selector));
+ _images.forEach((img, i) => {
+ img.style.cursor = 'zoom-in';
+ img.setAttribute('tabindex', '0');
+ img.removeEventListener('click', img._lbHandler);
+ img.removeEventListener('keydown', img._lbKeyHandler);
+ img._lbHandler = () => _show(i);
+ img._lbKeyHandler = e => { if (e.key === 'Enter' || e.key === ' ') _show(i); };
+ img.addEventListener('click', img._lbHandler);
+ img.addEventListener('keydown', img._lbKeyHandler);
+ });
+ }
+ _collect();
+
+ return Object.assign(lightbox, {
+ open: idx => _show(idx),
+ close() {
+ if (!_overlay) return;
+ _overlay.classList.remove('is-open');
+ _unlockScroll();
+ },
+ prev() { _show(loop ? (_current - 1 + _images.length) % _images.length : Math.max(0, _current - 1)); },
+ next() { _show(loop ? (_current + 1) % _images.length : Math.min(_images.length - 1, _current + 1)); },
+ refresh: _collect,
+ });
+ },
+ };
+
+ /* ================================================================
+ MODULE 53 — AUTH / JWT HELPERS
+ Extends lt.api with token refresh support.
+ lt.auth.setToken(accessToken, refreshToken, expiresIn)
+ lt.auth.getToken()
+ lt.auth.refresh() — explicit refresh
+ lt.auth.onExpire(fn) — callback when token expires & refresh fails
+ lt.auth.clear()
+ Auto-intercepts lt.api calls to inject Bearer header
+ and silently refreshes when token is within 60s of expiry.
+ ================================================================ */
+ let _authAccess = null;
+ let _authRefresh = null;
+ let _authExpiry = 0; // epoch ms
+ let _authRefreshUrl = null;
+ let _authRefreshing = null; // in-flight promise
+ const _authExpireHandlers = [];
+
+ const auth = {
+ setToken(access, refresh, expiresIn, refreshUrl) {
+ _authAccess = access;
+ _authRefresh = refresh;
+ _authExpiry = expiresIn ? Date.now() + expiresIn * 1000 : 0;
+ if (refreshUrl) _authRefreshUrl = refreshUrl;
+ try { sessionStorage.setItem('lt_auth_access', access); } catch(_) {}
+ },
+ getToken: () => _authAccess,
+ clear() {
+ _authAccess = _authRefresh = null; _authExpiry = 0;
+ try { sessionStorage.removeItem('lt_auth_access'); } catch(_) {}
+ bus.emit('auth:logout');
+ },
+ onExpire: fn => _authExpireHandlers.push(fn),
+ async refresh() {
+ if (!_authRefreshUrl || !_authRefresh) return false;
+ if (_authRefreshing) return _authRefreshing;
+ _authRefreshing = fetch(_authRefreshUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ refresh_token: _authRefresh }),
+ })
+ .then(r => r.json())
+ .then(data => {
+ if (data.access_token) {
+ _authAccess = data.access_token;
+ _authExpiry = data.expires_in ? Date.now() + data.expires_in * 1000 : 0;
+ bus.emit('auth:refreshed');
+ return true;
+ }
+ throw new Error('Refresh failed');
+ })
+ .catch(e => {
+ console.error('[lt.auth]', e);
+ _authExpireHandlers.forEach(fn => fn());
+ bus.emit('auth:expired');
+ return false;
+ })
+ .finally(() => { _authRefreshing = null; });
+ return _authRefreshing;
+ },
+ isExpired: () => _authExpiry > 0 && Date.now() >= _authExpiry,
+ isExpiringSoon: (secs = 60) => _authExpiry > 0 && Date.now() >= _authExpiry - secs * 1000,
+ };
+
+ // Patch lt.api to inject Authorization header and auto-refresh
+ const _origApiFetch = apiFetch;
+ async function apiFetch(method, url, body) {
+ if (_authAccess) {
+ if (auth.isExpiringSoon()) await auth.refresh();
+ }
+ const opts = { method, headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()) };
+ if (_authAccess) opts.headers['Authorization'] = 'Bearer ' + _authAccess;
+ if (body !== undefined) opts.body = JSON.stringify(body);
+ let resp;
+ try { resp = await fetch(url, opts); } catch (err) { throw new Error('Network error: ' + err.message); }
+ // Auto-retry once on 401 after token refresh
+ if (resp.status === 401 && _authRefresh) {
+ const ok = await auth.refresh();
+ if (ok) {
+ opts.headers['Authorization'] = 'Bearer ' + _authAccess;
+ try { resp = await fetch(url, opts); } catch (err) { throw new Error('Network error: ' + err.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;
+ }
+ // Re-point api methods to patched apiFetch
+ api.get = url => apiFetch('GET', url);
+ api.post = (u, b) => apiFetch('POST', u, b);
+ api.put = (u, b) => apiFetch('PUT', u, b);
+ api.patch = (u, b) => apiFetch('PATCH', u, b);
+ api.delete = (u, b) => apiFetch('DELETE', u, b);
+
+ /* ================================================================
+ MODULE 54 — MARKDOWN RENDERER
+ lt.markdown.render(mdString) → HTML string (sanitized)
+ lt.markdown.init(selector) → renders all matching el's .textContent
+ Uses a built-in micro-renderer (no deps) for common syntax.
+ For full GFM, swap in marked.js: window.marked && marked.parse()
+ ================================================================ */
+ const markdown = {
+ render(md) {
+ // Delegate to window.marked if available
+ if (global.marked) return global.marked.parse(md);
+ if (global.markdownit) return global.markdownit().render(md);
+ // Micro-renderer: covers headings, bold, italic, code, links, lists, blockquote, hr
+ let html = escHtml(md)
+ // Fenced code blocks
+ .replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => `${code.trim()}
`)
+ // Inline code
+ .replace(/`([^`]+)`/g, '$1')
+ // Headings
+ .replace(/^######\s(.+)$/gm, '$1
')
+ .replace(/^#####\s(.+)$/gm, '$1
')
+ .replace(/^####\s(.+)$/gm, '$1
')
+ .replace(/^###\s(.+)$/gm, '$1
')
+ .replace(/^##\s(.+)$/gm, '$1
')
+ .replace(/^#\s(.+)$/gm, '$1
')
+ // Bold / italic
+ .replace(/\*\*\*(.+?)\*\*\*/g, '$1')
+ .replace(/\*\*(.+?)\*\*/g, '$1')
+ .replace(/\*(.+?)\*/g, '$1')
+ .replace(/__(.+?)__/g, '$1')
+ .replace(/_(.+?)_/g, '$1')
+ // Links
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
+ // Images
+ .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '
')
+ // Blockquote
+ .replace(/^>\s(.+)$/gm, '$1
')
+ // Horizontal rule
+ .replace(/^(-{3,}|\*{3,}|_{3,})$/gm, '
')
+ // Unordered list items
+ .replace(/^[-*+]\s(.+)$/gm, '$1')
+ .replace(/([\s\S]+?<\/li>\n?)+/g, m => ``)
+ // Ordered list items
+ .replace(/^\d+\.\s(.+)$/gm, '$1')
+ // Paragraphs (double newline)
+ .replace(/\n{2,}/g, '')
+ .replace(/\n/g, '
');
+ return `
${html}
`
+ .replace(/(<(?:pre|ul|ol|h[1-6]|blockquote|hr)[^>]*>)/g, '$1')
+ .replace(/(<\/(?:pre|ul|ol|h[1-6]|blockquote|hr)>)<\/p>/g, '$1');
+ },
+
+ init(selector) {
+ document.querySelectorAll(selector).forEach(el => {
+ const raw = el.getAttribute('data-markdown') || el.textContent;
+ el.innerHTML = markdown.render(raw);
+ el.classList.add('lt-markdown');
+ });
+ },
+ };
+
/* ================================================================
PUBLIC API
---------------------------------------------------------------- */
@@ -2125,6 +2698,14 @@
typeahead,
cookie,
splitPane,
+ infiniteScroll,
+ wizard,
+ sortable,
+ timer,
+ lightbox,
+ auth,
+ markdown,
+ sidebarSubmenus: { init: initSidebarSubmenus },
};
}(window));