diff --git a/base.css b/base.css index 71a18c1..d04358f 100644 --- a/base.css +++ b/base.css @@ -3266,3 +3266,930 @@ input[type="range"].lt-range::-moz-range-thumb { /* Monospace table-style number alignment */ .lt-num { font-variant-numeric: tabular-nums; font-feature-settings: "tnum"; } + + +/* ================================================================ + v1.1 COMPONENT EXTENSIONS + 51. Light Theme + 52. Theme Toggle Button + 53. Skeleton Loader Variants + 54. Empty State Enhancements + 55. Nav Notification Badge + 56. Right-Side Drawer + 57. Sticky Table + 58. Multi-Select / Combobox + 59. Context Menu + 60. Offline Banner + 61. Timeline / Activity Feed + 62. Avatar + 63. Split Pane + 64. Chart Container + 65. Toast Queue + 66. Autocomplete / Typeahead + 67. WebSocket Status + 68. Print Enhancements + ================================================================ */ + + +/* ---------------------------------------------------------------- + 51. LIGHT THEME + ---------------------------------------------------------------- */ +html[data-theme="light"] { + --bg-primary: #eef1f6; + --bg-secondary: #e2e6ed; + --bg-tertiary: #d5dae3; + --bg-card: #ffffff; + --bg-terminal: #f7f9fc; + --bg-overlay: rgba(238,241,246,0.96); + --bg-input: #ffffff; + + --text-primary: #1a2035; + --text-secondary: #3a4a6a; + --text-muted: #647898; + --text-dim: #8fa0b8; + + --border-color: rgba(0,100,180,0.20); + --border-color-hi: var(--accent-cyan); + --border-color-dim: rgba(0,100,180,0.10); + --border-dim: rgba(0,100,180,0.10); + + /* Slightly muted glows for light bg */ + --glow-orange: 0 0 4px rgba(255,107,0,0.6), 0 0 10px rgba(255,107,0,0.3); + --glow-cyan: 0 0 4px rgba(0,150,200,0.6), 0 0 10px rgba(0,150,200,0.3); + --glow-green: 0 0 4px rgba(0,180,80,0.5), 0 0 10px rgba(0,180,80,0.25); + --glow-red: 0 0 4px rgba(220,0,50,0.5), 0 0 10px rgba(220,0,50,0.25); + --glow-amber: 0 0 4px rgba(200,130,0,0.5), 0 0 10px rgba(200,130,0,0.25); + + color-scheme: light; +} +/* Hide CRT overlays in light mode */ +html[data-theme="light"] body::before, +html[data-theme="light"] body::after { display: none; } +/* Soften dot grid */ +html[data-theme="light"] body { + background-image: radial-gradient(circle, rgba(0,80,160,0.10) 1px, transparent 1px); +} +/* Nav / header adjustments */ +html[data-theme="light"] .lt-header { + background: rgba(238,241,246,0.96); + border-bottom-color: var(--border-color); +} +html[data-theme="light"] .lt-nav-link, +html[data-theme="light"] .lt-nav-link:hover { color: var(--text-secondary); } +html[data-theme="light"] .lt-nav-link.active { color: var(--accent-orange); } +html[data-theme="light"] .lt-sidebar { background: var(--bg-secondary); border-color: var(--border-color); } +html[data-theme="light"] .lt-card, +html[data-theme="light"] .lt-frame { background: var(--bg-card); border-color: var(--border-color); } +html[data-theme="light"] .lt-section { background: var(--bg-card); border-color: var(--border-color); } +html[data-theme="light"] .lt-input, +html[data-theme="light"] .lt-select, +html[data-theme="light"] .lt-textarea { background: var(--bg-input); border-color: var(--border-color); color: var(--text-primary); } +html[data-theme="light"] .lt-table th { background: var(--bg-secondary); } +html[data-theme="light"] .lt-table tr:hover td { background: rgba(0,100,200,0.04); } +html[data-theme="light"] .lt-nav-drawer { background: var(--bg-card); } +html[data-theme="light"] code, +html[data-theme="light"] .lt-code-block { background: var(--bg-secondary); color: var(--text-primary); } + + +/* ---------------------------------------------------------------- + 52. THEME TOGGLE BUTTON + ---------------------------------------------------------------- */ +.lt-theme-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; height: 32px; + border-radius: 4px; + border: 1px solid var(--border-color); + background: transparent; + color: var(--accent-cyan); + font-size: 1rem; + cursor: pointer; + transition: var(--transition-fast); + flex-shrink: 0; +} +.lt-theme-btn:hover { + background: var(--accent-cyan-dim); + border-color: var(--accent-cyan-border); + box-shadow: var(--box-glow-cyan); +} +.lt-theme-btn:focus-visible { + outline: 1px solid var(--accent-cyan); + outline-offset: 2px; +} +@media (pointer: coarse) { + .lt-theme-btn { width: 44px; height: 44px; font-size: 1.2rem; } +} + + +/* ---------------------------------------------------------------- + 53. SKELETON LOADER VARIANTS + (Extends existing .lt-skeleton in section 19) + ---------------------------------------------------------------- */ +/* Override section 19 shimmer with improved cyan-tinted version */ +.lt-skeleton { + background: linear-gradient( + 90deg, + var(--bg-secondary) 25%, + rgba(0,212,255,0.05) 50%, + var(--bg-secondary) 75% + ); + background-size: 200% 100%; + animation: lt-shimmer 1.6s ease-in-out infinite; + border-radius: 2px; + display: block; +} +@keyframes lt-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} +@media (prefers-reduced-motion: reduce) { + .lt-skeleton { animation: none; background: var(--bg-secondary); } +} +@media (pointer: coarse) { + .lt-skeleton { animation: none; } +} +/* Size variants */ +.lt-skeleton-text { height: 0.8rem; width: 100%; margin-bottom: 0.4rem; } +.lt-skeleton-title { height: 1.1rem; width: 55%; margin-bottom: 0.6rem; } +.lt-skeleton-avatar { height: 2.25rem; width: 2.25rem; border-radius: 50%; flex-shrink: 0; } +.lt-skeleton-btn { height: 1.9rem; width: 5.5rem; } +.lt-skeleton-badge { height: 1.1rem; width: 4rem; border-radius: 999px; } +.lt-skeleton-line-sm { height: 0.7rem; width: 40%; margin-bottom: 0.3rem; } +.lt-skeleton-line-lg { height: 0.8rem; width: 80%; margin-bottom: 0.4rem; } + +/* Card skeleton */ +.lt-skeleton-card { + background: var(--bg-card); + border: 1px solid var(--border-dim); + clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 0 100%); + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.4rem; +} +.lt-skeleton-card-header { + display: flex; + gap: 0.75rem; + align-items: center; + margin-bottom: 0.4rem; +} +/* Table row skeleton */ +.lt-skeleton-row { + display: grid; + grid-template-columns: 1.5rem 1fr 2.5fr 1fr 1fr 1fr; + gap: 1rem; + padding: 0.65rem 1rem; + border-bottom: 1px solid var(--border-dim); + align-items: center; +} +@media (max-width: 639px) { + .lt-skeleton-row { grid-template-columns: 1fr 2fr; gap: 0.5rem; } +} + + +/* ---------------------------------------------------------------- + 54. EMPTY STATE ENHANCEMENTS + (Extends existing .lt-empty in section 19) + ---------------------------------------------------------------- */ +.lt-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem 2rem; + text-align: center; + gap: 0.6rem; +} +.lt-empty-state-icon { + font-size: 2.5rem; + opacity: 0.35; + margin-bottom: 0.25rem; + line-height: 1; +} +.lt-empty-state-title { + color: var(--text-secondary); + font-size: 0.8rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; +} +.lt-empty-state-body { + color: var(--text-muted); + font-size: 0.72rem; + max-width: 30ch; + line-height: 1.7; +} +.lt-empty-state .lt-btn { margin-top: 0.5rem; } +/* Small inline variant */ +.lt-empty-state--sm { + padding: 1.5rem 1rem; +} +.lt-empty-state--sm .lt-empty-state-icon { font-size: 1.5rem; } +.lt-empty-state--sm .lt-empty-state-title { font-size: 0.72rem; } + + +/* ---------------------------------------------------------------- + 55. NAV NOTIFICATION BADGE + ---------------------------------------------------------------- */ +.lt-notif-wrap { + position: relative; + display: inline-flex; +} +.lt-notif-badge { + position: absolute; + top: -4px; + right: -4px; + min-width: 1rem; + height: 1rem; + padding: 0 0.2rem; + background: var(--accent-red); + color: #fff; + font-size: 0.58rem; + font-weight: 700; + border-radius: 999px; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + border: 1.5px solid var(--bg-primary); + pointer-events: none; + animation: lt-notif-pulse 2.5s ease-in-out infinite; +} +.lt-notif-badge[data-count="0"], +.lt-notif-badge:empty { display: none; } +@keyframes lt-notif-pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(255,45,85,0.7); } + 50% { box-shadow: 0 0 0 5px rgba(255,45,85,0); } +} +@media (pointer: coarse) { .lt-notif-badge { animation: none; } } + + +/* ---------------------------------------------------------------- + 56. RIGHT-SIDE DRAWER + ---------------------------------------------------------------- */ +.lt-drawer-right { + position: fixed; + top: var(--header-height); + right: 0; + height: calc(100vh - var(--header-height)); + width: 400px; + max-width: 92vw; + background: var(--bg-card); + border-left: 1px solid var(--border-color); + clip-path: polygon(10px 0, 100% 0, 100% 100%, 0 100%, 0 10px); + transform: translateX(100%); + transition: transform 0.25s cubic-bezier(0.4,0,0.2,1); + z-index: calc(var(--z-overlay) + 2); /* 10001 — same level as nav overlay */ + display: flex; + flex-direction: column; + overflow: hidden; +} +.lt-drawer-right.is-open { transform: translateX(0); } +.lt-drawer-right-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border-dim); + flex-shrink: 0; +} +.lt-drawer-right-title { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--accent-cyan); + text-shadow: var(--glow-cyan); +} +.lt-drawer-right-close { + width: 32px; height: 32px; + display: flex; align-items: center; justify-content: center; + background: none; + border: 1px solid transparent; + color: var(--text-muted); + font-size: 1rem; + cursor: pointer; + border-radius: 2px; + transition: var(--transition-fast); +} +.lt-drawer-right-close:hover { color: var(--accent-red); border-color: var(--accent-red); } +.lt-drawer-right-close:focus-visible { outline: 1px solid var(--accent-cyan); outline-offset: 2px; } +.lt-drawer-right-body { + flex: 1; + overflow-y: auto; + padding: 1rem; + overscroll-behavior: contain; +} +.lt-drawer-right-footer { + padding: 0.75rem 1rem; + border-top: 1px solid var(--border-dim); + display: flex; + gap: 0.5rem; + justify-content: flex-end; + flex-shrink: 0; +} +/* Overlay for right drawer */ +.lt-drawer-right-overlay { + position: fixed; + inset: 0; + background: rgba(3,5,8,0.65); + z-index: calc(var(--z-overlay) + 1); /* 10000 */ + opacity: 0; + pointer-events: none; + transition: opacity 0.25s ease; +} +.lt-drawer-right-overlay.is-open { opacity: 1; pointer-events: auto; } +@media (max-width: 479px) { + .lt-drawer-right { width: 100vw; max-width: 100vw; top: 0; height: 100vh; border-left: none; border-top: 1px solid var(--border-color); } +} +@media (pointer: coarse) { + .lt-drawer-right-close { width: 44px; height: 44px; } +} + + +/* ---------------------------------------------------------------- + 57. STICKY TABLE + ---------------------------------------------------------------- */ +.lt-table-sticky-wrap { + max-height: 420px; + overflow-y: auto; + overflow-x: auto; + overscroll-behavior: contain; + border: 1px solid var(--border-dim); + border-radius: 2px; +} +.lt-table-sticky-wrap .lt-table { margin: 0; border: none; } +.lt-table-sticky-wrap .lt-table thead th { + position: sticky; + top: 0; + z-index: 2; + background: var(--bg-tertiary); + backdrop-filter: blur(6px); + border-bottom: 2px solid var(--border-color); + box-shadow: 0 2px 8px rgba(3,5,8,0.5); +} +/* Custom scrollbar inside sticky wrap */ +.lt-table-sticky-wrap::-webkit-scrollbar { width: 6px; height: 6px; } +.lt-table-sticky-wrap::-webkit-scrollbar-track { background: var(--bg-primary); } +.lt-table-sticky-wrap::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; } + + +/* ---------------------------------------------------------------- + 58. MULTI-SELECT / COMBOBOX + ---------------------------------------------------------------- */ +.lt-combobox { + position: relative; + display: block; +} +.lt-combobox-input-wrap { + display: flex; + flex-wrap: wrap; + gap: 4px; + align-items: center; + min-height: 36px; + padding: 4px 8px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + cursor: text; + transition: border-color 0.15s; +} +.lt-combobox-input-wrap:focus-within { + border-color: var(--accent-cyan); + box-shadow: var(--box-glow-cyan); +} +.lt-combobox-input-wrap .lt-combobox-input { + flex: 1; + min-width: 80px; + background: none; + border: none; + outline: none; + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 0.8rem; + padding: 2px 0; +} +/* Selected tag chips inside the input */ +.lt-combobox-tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px 6px; + background: var(--accent-cyan-dim); + border: 1px solid var(--accent-cyan-border); + color: var(--accent-cyan); + font-size: 0.7rem; + border-radius: 2px; + white-space: nowrap; +} +.lt-combobox-tag-remove { + background: none; border: none; cursor: pointer; + color: var(--text-muted); font-size: 0.75rem; padding: 0; line-height: 1; + display: flex; align-items: center; +} +.lt-combobox-tag-remove:hover { color: var(--accent-red); } +/* Dropdown list */ +.lt-combobox-dropdown { + position: absolute; + top: calc(100% + 2px); + left: 0; right: 0; + background: var(--bg-card); + border: 1px solid var(--border-color); + clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 0 100%); + z-index: var(--z-dropdown); + max-height: 200px; + overflow-y: auto; + display: none; +} +.lt-combobox-dropdown.is-open { display: block; } +.lt-combobox-option { + padding: 0.4rem 0.75rem; + font-size: 0.78rem; + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; + transition: background 0.1s; +} +.lt-combobox-option:hover, +.lt-combobox-option.is-focused { background: var(--accent-cyan-dim); color: var(--accent-cyan); } +.lt-combobox-option.is-selected::before { + content: '✓'; + color: var(--accent-green); + font-size: 0.7rem; + width: 0.8rem; + flex-shrink: 0; +} +.lt-combobox-option:not(.is-selected)::before { content: ''; width: 0.8rem; display: inline-block; flex-shrink: 0; } +.lt-combobox-empty { + padding: 0.6rem 0.75rem; + font-size: 0.72rem; + color: var(--text-muted); + text-align: center; +} + + +/* ---------------------------------------------------------------- + 59. CONTEXT MENU + ---------------------------------------------------------------- */ +.lt-context-menu { + position: fixed; + background: var(--bg-card); + border: 1px solid var(--border-color); + clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 0 100%); + min-width: 160px; + z-index: var(--z-tooltip); + box-shadow: 0 8px 32px rgba(0,0,0,0.45); + padding: 4px 0; + display: none; +} +.lt-context-menu.is-open { display: block; } +.lt-context-menu-item { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.4rem 0.75rem; + font-size: 0.78rem; + color: var(--text-secondary); + cursor: pointer; + transition: background 0.1s; + user-select: none; + white-space: nowrap; +} +.lt-context-menu-item:hover { background: var(--accent-cyan-dim); color: var(--accent-cyan); } +.lt-context-menu-item.is-danger:hover { background: var(--accent-red-dim); color: var(--accent-red); } +.lt-context-menu-item .icon { width: 1rem; text-align: center; opacity: 0.7; font-size: 0.75rem; } +.lt-context-menu-item kbd { + margin-left: auto; + font-size: 0.6rem; + color: var(--text-muted); + background: var(--bg-tertiary); + border: 1px solid var(--border-dim); + padding: 1px 4px; + border-radius: 2px; +} +.lt-context-menu-divider { + height: 1px; + background: var(--border-dim); + margin: 4px 0; +} +.lt-context-menu-label { + padding: 0.25rem 0.75rem; + font-size: 0.62rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-muted); + pointer-events: none; +} + + +/* ---------------------------------------------------------------- + 60. OFFLINE BANNER + ---------------------------------------------------------------- */ +.lt-offline-banner { + position: fixed; + top: var(--header-height); + left: 0; right: 0; + padding: 0.4rem 1rem; + background: var(--accent-red-dim); + border-top: 1px solid var(--accent-red); + border-bottom: 1px solid var(--accent-red); + color: var(--accent-red); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.1em; + text-align: center; + z-index: var(--z-fixed); + transform: translateY(-100%); + transition: transform 0.25s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} +.lt-offline-banner.is-visible { transform: translateY(0); } +.lt-offline-banner .lt-dot--red { + animation: lt-pulse 1s ease-in-out infinite; +} +@keyframes lt-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} +body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s ease; } + + +/* ---------------------------------------------------------------- + 61. TIMELINE / ACTIVITY FEED + ---------------------------------------------------------------- */ +.lt-timeline { + display: flex; + flex-direction: column; + gap: 0; + padding-left: 1.25rem; + border-left: 1px solid var(--border-dim); + margin-left: 0.5rem; +} +.lt-timeline-item { + position: relative; + padding: 0 0 1.25rem 1.25rem; +} +.lt-timeline-item:last-child { padding-bottom: 0; } +/* Connector dot */ +.lt-timeline-item::before { + content: ''; + position: absolute; + left: -5px; + top: 4px; + width: 8px; height: 8px; + border-radius: 50%; + background: var(--accent-cyan); + box-shadow: 0 0 6px var(--accent-cyan); + border: 1.5px solid var(--bg-primary); +} +.lt-timeline-item--orange::before { background: var(--accent-orange); box-shadow: var(--glow-orange); } +.lt-timeline-item--green::before { background: var(--accent-green); box-shadow: var(--glow-green); } +.lt-timeline-item--red::before { background: var(--accent-red); box-shadow: var(--glow-red); } +.lt-timeline-item--dim::before { background: var(--text-muted); box-shadow: none; } +.lt-timeline-meta { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.68rem; + color: var(--text-muted); + margin-bottom: 0.2rem; + flex-wrap: wrap; +} +.lt-timeline-actor { color: var(--accent-cyan); } +.lt-timeline-time { margin-left: auto; white-space: nowrap; } +.lt-timeline-body { font-size: 0.78rem; color: var(--text-secondary); line-height: 1.5; } +.lt-timeline-body code { font-size: 0.72rem; color: var(--accent-green); } + + +/* ---------------------------------------------------------------- + 62. AVATAR + ---------------------------------------------------------------- */ +.lt-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--bg-tertiary); + border: 1.5px solid var(--border-color); + color: var(--accent-cyan); + font-weight: 700; + font-size: 0.75rem; + text-transform: uppercase; + overflow: hidden; + flex-shrink: 0; + user-select: none; +} +.lt-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; } +/* Sizes */ +.lt-avatar--xs { width: 1.5rem; height: 1.5rem; font-size: 0.55rem; } +.lt-avatar--sm { width: 2rem; height: 2rem; font-size: 0.65rem; } +.lt-avatar { width: 2.5rem; height: 2.5rem; font-size: 0.75rem; } /* default */ +.lt-avatar--lg { width: 3.5rem; height: 3.5rem; font-size: 1rem; } +.lt-avatar--xl { width: 5rem; height: 5rem; font-size: 1.4rem; } +/* Color variants */ +.lt-avatar--orange { background: var(--accent-orange-dim); border-color: var(--accent-orange-border); color: var(--accent-orange); } +.lt-avatar--green { background: var(--accent-green-dim); border-color: var(--accent-green-border); color: var(--accent-green); } +.lt-avatar--red { background: var(--accent-red-dim); border-color: var(--accent-red); color: var(--accent-red); } +.lt-avatar--purple { background: var(--accent-purple-dim); border-color: var(--accent-purple); color: var(--accent-purple); } +/* Avatar group (overlapping row) */ +.lt-avatar-group { display: flex; } +.lt-avatar-group .lt-avatar { + margin-left: -0.5rem; + border: 2px solid var(--bg-primary); + transition: transform 0.15s; +} +.lt-avatar-group .lt-avatar:first-child { margin-left: 0; } +.lt-avatar-group .lt-avatar:hover { transform: translateY(-2px) scale(1.08); z-index: 1; } +/* Status ring */ +.lt-avatar-wrap { + position: relative; + display: inline-flex; + flex-shrink: 0; +} +.lt-avatar-status { + position: absolute; + bottom: 1px; right: 1px; + width: 9px; height: 9px; + border-radius: 50%; + border: 1.5px solid var(--bg-primary); + background: var(--text-muted); +} +.lt-avatar-status--online { background: var(--accent-green); box-shadow: 0 0 4px var(--accent-green); } +.lt-avatar-status--away { background: var(--accent-amber); } +.lt-avatar-status--busy { background: var(--accent-red); } + + +/* ---------------------------------------------------------------- + 63. SPLIT PANE + ---------------------------------------------------------------- */ +.lt-split { + display: flex; + width: 100%; + height: 100%; + overflow: hidden; + position: relative; +} +.lt-split--vertical { flex-direction: column; } +.lt-split-pane { + overflow: auto; + flex: 1; + min-width: 0; + min-height: 0; +} +.lt-split-divider { + flex: 0 0 5px; + background: var(--border-dim); + cursor: col-resize; + transition: background 0.15s; + position: relative; + user-select: none; + touch-action: none; +} +.lt-split-divider::after { + content: '⠿'; + position: absolute; + top: 50%; left: 50%; + transform: translate(-50%, -50%); + color: var(--text-muted); + font-size: 0.6rem; + pointer-events: none; +} +.lt-split-divider:hover, +.lt-split-divider.is-dragging { background: var(--accent-cyan-border); } +.lt-split--vertical .lt-split-divider { cursor: row-resize; } +/* On mobile, stack vertically and hide divider */ +@media (max-width: 767px) { + .lt-split:not(.lt-split--vertical) { flex-direction: column; } + .lt-split:not(.lt-split--vertical) .lt-split-divider { display: none; } + .lt-split:not(.lt-split--vertical) .lt-split-pane { flex: 0 0 auto; } +} + + +/* ---------------------------------------------------------------- + 64. CHART CONTAINER + ---------------------------------------------------------------- */ +.lt-chart-wrap { + position: relative; + background: var(--bg-card); + border: 1px solid var(--border-dim); + clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 0 100%); + padding: 1rem; + overflow: hidden; +} +.lt-chart-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; + flex-wrap: wrap; + gap: 0.5rem; +} +.lt-chart-title { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-secondary); +} +.lt-chart-legend { + display: flex; + gap: 1rem; + font-size: 0.68rem; + color: var(--text-muted); + flex-wrap: wrap; +} +.lt-chart-legend-item { + display: flex; + align-items: center; + gap: 0.35rem; +} +.lt-chart-legend-dot { + width: 8px; height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.lt-chart-body { + position: relative; + width: 100%; +} +/* Sparkline — inline mini chart */ +.lt-sparkline { + display: inline-block; + vertical-align: middle; +} +/* Axis labels */ +.lt-chart-axis { + display: flex; + justify-content: space-between; + font-size: 0.62rem; + color: var(--text-muted); + margin-top: 0.25rem; +} +/* Loading / no-data states */ +.lt-chart-wrap.is-loading .lt-chart-body { min-height: 120px; } +.lt-chart-wrap.is-loading .lt-chart-body::after { + content: 'LOADING DATA...'; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.68rem; + color: var(--text-muted); + letter-spacing: 0.1em; +} + + +/* ---------------------------------------------------------------- + 65. TOAST QUEUE (enhanced multi-toast stack) + ---------------------------------------------------------------- */ +/* Toast container already exists in section 14; these are addons */ +#lt-toast-container { + /* Stack from bottom — newest on top */ + display: flex; + flex-direction: column-reverse; + gap: 0.5rem; +} +.lt-toast + .lt-toast { margin-top: 0; } /* gap handles spacing */ +/* Progress bar on toast (auto-dismiss countdown) */ +.lt-toast-progress { + position: absolute; + bottom: 0; left: 0; + height: 2px; + background: currentColor; + opacity: 0.4; + animation: lt-toast-drain linear forwards; +} +@keyframes lt-toast-drain { + from { width: 100%; } + to { width: 0; } +} +/* Pause on hover */ +.lt-toast:hover .lt-toast-progress { animation-play-state: paused; } + + +/* ---------------------------------------------------------------- + 66. AUTOCOMPLETE / TYPEAHEAD + ---------------------------------------------------------------- */ +.lt-typeahead { + position: relative; + display: block; +} +.lt-typeahead-dropdown { + position: absolute; + top: calc(100% + 2px); + left: 0; right: 0; + background: var(--bg-card); + border: 1px solid var(--border-color); + clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 0 100%); + z-index: var(--z-dropdown); + max-height: 220px; + overflow-y: auto; + display: none; +} +.lt-typeahead-dropdown.is-open { display: block; } +.lt-typeahead-item { + padding: 0.4rem 0.75rem; + font-size: 0.78rem; + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; + transition: background 0.1s; +} +.lt-typeahead-item:hover, +.lt-typeahead-item.is-focused { background: var(--accent-cyan-dim); color: var(--accent-cyan); } +.lt-typeahead-item mark { + background: none; + color: var(--accent-orange); + font-weight: 700; +} +.lt-typeahead-item .icon { + font-size: 0.75rem; + opacity: 0.6; + width: 1rem; + text-align: center; + flex-shrink: 0; +} +.lt-typeahead-empty { + padding: 0.6rem 0.75rem; + font-size: 0.72rem; + color: var(--text-muted); + text-align: center; + letter-spacing: 0.05em; +} +/* Loading state in dropdown */ +.lt-typeahead-loading { + padding: 0.6rem 0.75rem; + font-size: 0.72rem; + color: var(--text-muted); + display: flex; + align-items: center; + gap: 0.5rem; +} +.lt-typeahead-loading::before { + content: ''; + width: 10px; height: 10px; + border: 1.5px solid var(--border-color); + border-top-color: var(--accent-cyan); + border-radius: 50%; + animation: spin 0.7s linear infinite; + flex-shrink: 0; +} + + +/* ---------------------------------------------------------------- + 67. WEBSOCKET STATUS INDICATOR + ---------------------------------------------------------------- */ +.lt-ws-status { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + padding: 2px 8px; + border: 1px solid var(--border-dim); + border-radius: 2px; +} +.lt-ws-status .lt-dot { flex-shrink: 0; } +.lt-ws-status[data-state="connected"] { color: var(--accent-green); border-color: var(--accent-green-border); } +.lt-ws-status[data-state="connecting"] { color: var(--accent-amber); border-color: var(--accent-amber-dim); } +.lt-ws-status[data-state="disconnected"] { color: var(--accent-red); border-color: var(--accent-red-dim); } +.lt-ws-status[data-state="connected"] .lt-dot { background: var(--accent-green); box-shadow: var(--glow-green); } +.lt-ws-status[data-state="connecting"] .lt-dot { background: var(--accent-amber); box-shadow: var(--glow-amber); animation: lt-pulse 0.8s ease-in-out infinite; } +.lt-ws-status[data-state="disconnected"] .lt-dot { background: var(--accent-red); } + + +/* ---------------------------------------------------------------- + 68. PRINT ENHANCEMENTS + (Extends section 25) + ---------------------------------------------------------------- */ +@media print { + .lt-sidebar, .lt-nav, .lt-header-right, + .lt-btn-group, .lt-menu-btn, + .lt-nav-drawer, .lt-nav-drawer-overlay, + .lt-offline-banner, .lt-context-menu, + [data-modal-open], [data-tooltip] { display: none !important; } + + .lt-section, .lt-card, .lt-frame { + break-inside: avoid; + box-shadow: none; + clip-path: none; + border: 1px solid #999; + } + .lt-table { border-collapse: collapse; } + .lt-table th, .lt-table td { border: 1px solid #ccc; } + .lt-table thead th { background: #eee !important; color: black !important; } + a[href]::after { content: " (" attr(href) ")"; font-size: 0.7em; color: #666; } + a[href^="#"]::after, a[href^="javascript"]::after { content: ""; } + .lt-code-block { white-space: pre-wrap; word-break: break-all; } + .lt-page-header { border-bottom: 2px solid #333; padding-bottom: 0.5rem; margin-bottom: 1rem; } +} diff --git a/base.html b/base.html index 3ecaa4d..d290243 100644 --- a/base.html +++ b/base.html @@ -111,6 +111,16 @@
+ +
+ Offline +
+ + + + + + operator @@ -118,6 +128,45 @@
+ + +
+ @@ -933,6 +982,280 @@ + + + + +
+
+ // THEME TOGGLE +
+
+

Dark/light mode with OS preference detection and localStorage persistence.

+
+ + + + lt.theme.toggle() | .set('light') | .get() +
+
+
+ + +
+
+ // SKELETON LOADERS +
+
+
+
+
+ +
+
+ + + +
+ + +
+
+
+
+
+
+
+
+
+
+ + +
+
+ // EMPTY STATES +
+
+
+
📭
No Tickets Found
No tickets match your current filters.
+
🔌
No Workers Online
All workers are offline or unreachable.
+
🗂
No Results
Try a different search term.
+
+
+
+ + +
+
+ // AVATARS & NOTIFICATION BADGES +
+
+
+ AB + CD + EF + GH +
JD
+
SK
+
MR
+
+ AA + BB + CC + +4 +
+
+
+ + + + +
+
+
+ + +
+
+ // TIMELINE / ACTIVITY FEED +
+
+
+
+
+
alertmanager fired14:22:05
+
CRITICAL: Storage array link-down on compute-storage-01. Ticket auto-created.
+
+
+
jdoe assigned14:24:11
+
Escalated to P1 Critical. Paged on-call team.
+
+
+
jdoe commented14:31:44
+
Confirmed NIC failure. Ordered replacement hardware. ETA 2h.
+
+
+
jdoe resolved16:55:00
+
Hardware replaced. Link restored. Monitoring for 30 min.
+
+
+
+
Resolution Time
2h 33m
+
Events
4
+
SLA Status
Within SLA
+
+
+
+
+ + +
+
+ // RIGHT-SIDE DRAWER +
+
+

Detail/inspect panel from the right. Focus trap, ESC close, overlay backdrop, return-focus.

+
+ + lt.rightDrawer.open('id') | .close() | .toggle() +
+
+
+ + +
+
+ // CONTEXT MENU +
+
+

Right-click any element with data-context-menu or trigger programmatically.

+
+
+ Right-click this card › +
+ +
+
+
+ + +
+
+ // COMBOBOX & TYPEAHEAD +
+
+
+
+ +
+
+ +
+
+
+
+
+ +
+ +
+
+
+
+
+
+ + +
+
+ // STICKY TABLE HEADERS +
+
+
+ + + + + + + + + + + +
IDPriorityTitleStatusAssignee
#001P1Link-down on compute-storage-01Openjdoe
#002P2Switch port flapping USW-Pro-24In Progresssmith
#003P3Scheduled SFP+ replacementPendingops-bot
#004P4SSL cert renewal wikiOpenadmin
#005P1RAID controller firmwareOpenjdoe
#006P2Backup job failure nas-01Closedbackup-bot
#007P3Prometheus alert rule tuningIn Progressops-team
+
+
+
+ + +
+
+ // CHART CONTAINERS +
+
+
+
+
+ Ticket Volume (7d) +
+ Open + Closed +
+
+
[ Plug in Chart.js / D3 here ]
+
MonTueWedThuFriSatSun
+
+
+
Worker Uptime
+
+
+
+
+
+ + +
+
+ // SPLIT PANE +
+
+
+
+ +

Drag the divider to resize. Stacks vertically on mobile.

+
+
+
+ +

Both panels maintain independent scrolling.

+
+
+

lt.splitPane.init(el, { initial: 0.4, minA: 120 })

+
+
+ + +
+
+ // WEBSOCKET & OFFLINE DETECTION +
+
+
+
Connected
+
Connecting…
+
Disconnected
+
+
+ + +
+

Offline banner + body class auto-applied on navigator.onLine change. WS manager has exponential backoff, event emitter, status indicator binding.

+
+
+ @@ -1111,6 +1434,62 @@ requestAnimationFrame(() => { bar.style.width = bar.dataset.width; }); }); + // Theme toggle button + document.getElementById('lt-theme-btn').addEventListener('click', () => lt.theme.toggle()); + + // Context menu: register demo menu items + lt.contextMenu.register('demo-ctx', [ + { icon: '📋', label: 'Copy ID', kbd: 'C', action: () => lt.toast.info('ID copied') }, + { icon: '👁', label: 'View Details', action: () => lt.rightDrawer.open('lt-detail-drawer') }, + { icon: '✏️', label: 'Edit Ticket', kbd: 'E', action: () => lt.toast.info('Edit…') }, + { divider: true }, + { icon: '🗑', label: 'Delete', danger: true, action: () => lt.toast.error('Deleted') }, + ]); + // Programmatic context menu button + const demoCtxBtn = document.getElementById('demo-ctx-btn'); + if (demoCtxBtn) demoCtxBtn.addEventListener('click', e => { + const r = demoCtxBtn.getBoundingClientRect(); + lt.contextMenu.show(r.left, r.bottom + 4, [ + { icon: '📋', label: 'Copy ID', action: () => lt.toast.info('Copied') }, + { icon: '✏️', label: 'Edit', action: () => lt.toast.info('Edit') }, + { divider: true }, + { icon: '🗑', label: 'Delete', danger: true, action: () => lt.toast.error('Deleted') }, + ]); + }); + + // Combobox demo + lt.combobox.init( + document.getElementById('demo-combobox-input'), + [ + { value: 'worker-01', label: 'worker-01', icon: '🖥' }, + { value: 'worker-02', label: 'worker-02', icon: '🖥' }, + { value: 'worker-03', label: 'worker-03', icon: '🖥' }, + { value: 'gpu-01', label: 'gpu-01', icon: '⚡' }, + { value: 'gpu-02', label: 'gpu-02', icon: '⚡' }, + { value: 'storage-01',label: 'storage-01',icon: '💾' }, + ], + { onChange: vals => console.log('[combobox]', vals) } + ); + + // Typeahead demo + lt.typeahead.init( + document.getElementById('demo-typeahead-input'), + [ + { value: '001', label: 'Link-down on compute-storage-01', icon: '🔴', meta: 'P1' }, + { value: '002', label: 'Switch port flapping USW-Pro-24', icon: '🟠', meta: 'P2' }, + { value: '003', label: 'Scheduled SFP+ replacement large1',icon: '🔵', meta: 'P3' }, + { value: '004', label: 'SSL cert renewal wiki.lotusguild.org', icon: '🟢', meta: 'P4' }, + { value: '005', label: 'RAID controller firmware update', icon: '🔴', meta: 'P1' }, + ], + { onSelect: item => lt.toast.info(`Selected: ${item.label}`) } + ); + + // Split pane demo + lt.splitPane.init(document.getElementById('demo-split'), { initial: 0.4, minA: 100, minB: 100 }); + + // Demo notification badge initial count + lt.notif.set('#lt-notif-bell', 3); + // Tab bar switching document.querySelectorAll('.lt-tab-bar').forEach(bar => { bar.addEventListener('click', e => { diff --git a/base.js b/base.js index 0159c44..68125b1 100644 --- a/base.js +++ b/base.js @@ -1447,7 +1447,630 @@ }; - /* ---------------------------------------------------------------- + /* ================================================================ + MODULE 37 — THEME TOGGLE + lt.theme.toggle() + lt.theme.set('light'|'dark') + lt.theme.get() + ================================================================ */ + const _themeKey = 'lt_theme'; + function _applyTheme(t) { + document.documentElement.setAttribute('data-theme', t); + try { localStorage.setItem(_themeKey, t); } catch(_) {} + document.querySelectorAll('.lt-theme-btn').forEach(btn => { + btn.textContent = t === 'light' ? '◐' : '☀'; + btn.setAttribute('aria-label', t === 'light' ? 'Switch to dark mode' : 'Switch to light mode'); + btn.setAttribute('title', t === 'light' ? 'Switch to dark mode' : 'Switch to light mode'); + }); + bus.emit('theme:change', { theme: t }); + } + const _initTheme = (function() { + let saved; + try { saved = localStorage.getItem(_themeKey); } catch(_) {} + return saved || (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'); + })(); + _applyTheme(_initTheme); + window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', e => { + let saved; try { saved = localStorage.getItem(_themeKey); } catch(_) {} + if (!saved) _applyTheme(e.matches ? 'light' : 'dark'); + }); + const theme = { + toggle: () => _applyTheme(document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light'), + set: t => _applyTheme(t), + get: () => document.documentElement.getAttribute('data-theme') || 'dark', + }; + + /* ================================================================ + MODULE 38 — NOTIFICATION BADGE + lt.notif.set(el, count) + lt.notif.inc(el) + lt.notif.clear(el) + el = CSS selector string or DOM element + ================================================================ */ + function _notifEl(el) { return typeof el === 'string' ? document.querySelector(el) : el; } + function _notifBadge(el) { + const wrap = _notifEl(el); if (!wrap) return null; + let b = wrap.querySelector(':scope > .lt-notif-badge'); + if (!b) { + b = document.createElement('span'); + b.className = 'lt-notif-badge'; + b.setAttribute('aria-live', 'polite'); + b.setAttribute('role', 'status'); + wrap.classList.add('lt-notif-wrap'); + wrap.appendChild(b); + } + return b; + } + const notif = { + set(el, n) { + const b = _notifBadge(el); if (!b) return; + const label = n > 99 ? '99+' : n > 0 ? String(n) : ''; + b.textContent = label; + b.setAttribute('data-count', n); + b.setAttribute('aria-label', n > 0 ? `${n} notification${n !== 1 ? 's' : ''}` : ''); + }, + inc(el) { + const b = _notifBadge(el); if (!b) return; + const cur = parseInt(b.getAttribute('data-count') || '0', 10); + notif.set(el, cur + 1); + }, + clear(el) { notif.set(el, 0); }, + }; + + /* ================================================================ + MODULE 39 — RIGHT-SIDE DRAWER + lt.rightDrawer.open(id) + lt.rightDrawer.close(id) + lt.rightDrawer.toggle(id) + ================================================================ */ + function _rdOpen(id, triggerEl) { + const drawer = typeof id === 'string' ? document.getElementById(id) : id; + if (!drawer) return; + const ovId = drawer.dataset.overlay || id + '-overlay'; + const ov = document.getElementById(ovId); + if (_mnOpen) _mnSetOpen(false); + drawer.classList.add('is-open'); + drawer.setAttribute('aria-hidden', 'false'); + if (ov) ov.classList.add('is-open'); + _lockScroll(); + if (triggerEl) _modalTriggers.set(drawer, triggerEl); + const first = drawer.querySelector(_FOCUSABLE); + if (first) setTimeout(() => first.focus(), 50); + // ESC to close + drawer._rdKeyHandler = e => { if (e.key === 'Escape') _rdClose(drawer); }; + document.addEventListener('keydown', drawer._rdKeyHandler); + // Overlay click + if (ov) ov._rdClick = () => _rdClose(drawer); + if (ov) ov.addEventListener('click', ov._rdClick); + // Close button + drawer.querySelectorAll('[data-drawer-close]').forEach(btn => { + btn._rdHandler = () => _rdClose(drawer); + btn.addEventListener('click', btn._rdHandler); + }); + } + function _rdClose(id) { + const drawer = typeof id === 'string' ? document.getElementById(id) : id; + if (!drawer || !drawer.classList.contains('is-open')) return; + const ovId = drawer.dataset.overlay || (drawer.id ? drawer.id + '-overlay' : null); + const ov = ovId ? document.getElementById(ovId) : null; + drawer.classList.remove('is-open'); + drawer.setAttribute('aria-hidden', 'true'); + if (ov) { ov.classList.remove('is-open'); if (ov._rdClick) { ov.removeEventListener('click', ov._rdClick); delete ov._rdClick; } } + _unlockScroll(); + if (drawer._rdKeyHandler) { document.removeEventListener('keydown', drawer._rdKeyHandler); delete drawer._rdKeyHandler; } + const trigger = _modalTriggers.get(drawer); + if (trigger) { trigger.focus(); _modalTriggers.delete(drawer); } + } + const rightDrawer = { + open: (id) => _rdOpen(id, document.activeElement !== document.body ? document.activeElement : null), + close: (id) => _rdClose(id), + toggle: (id) => { + const el = typeof id === 'string' ? document.getElementById(id) : id; + if (el && el.classList.contains('is-open')) _rdClose(el); else _rdOpen(id); + }, + }; + // data-drawer-open="drawer-id" trigger wiring + document.addEventListener('click', e => { + const btn = e.target.closest('[data-drawer-open]'); + if (btn) { e.preventDefault(); _rdOpen(btn.dataset.drawerOpen, btn); } + }); + + /* ================================================================ + MODULE 40 — CONTEXT MENU + lt.contextMenu.register(selector, items) + items = [{ label, icon, kbd, danger, divider, action }] + ================================================================ */ + let _ctxMenu = null; + let _ctxItems = []; + function _ctxShow(x, y, items) { + if (!_ctxMenu) { + _ctxMenu = document.createElement('div'); + _ctxMenu.className = 'lt-context-menu'; + _ctxMenu.setAttribute('role', 'menu'); + document.body.appendChild(_ctxMenu); + } + _ctxMenu.innerHTML = ''; + items.forEach(item => { + if (item.divider) { const d = document.createElement('div'); d.className = 'lt-context-menu-divider'; _ctxMenu.appendChild(d); return; } + if (item.label && !item.action) { const l = document.createElement('div'); l.className = 'lt-context-menu-label'; l.textContent = item.label; _ctxMenu.appendChild(l); return; } + const el = document.createElement('div'); + el.className = 'lt-context-menu-item' + (item.danger ? ' is-danger' : ''); + el.setAttribute('role', 'menuitem'); + el.setAttribute('tabindex', '0'); + el.innerHTML = `${item.icon ? `${escHtml(item.icon)}` : ''}${escHtml(item.label || '')}${item.kbd ? `${escHtml(item.kbd)}` : ''}`; + el.addEventListener('click', () => { _ctxHide(); if (item.action) item.action(); }); + el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); el.click(); } }); + _ctxMenu.appendChild(el); + }); + _ctxMenu.classList.add('is-open'); + // Position — keep on screen + const vw = window.innerWidth, vh = window.innerHeight; + const mw = _ctxMenu.offsetWidth || 180, mh = _ctxMenu.offsetHeight || 200; + _ctxMenu.style.left = Math.min(x, vw - mw - 8) + 'px'; + _ctxMenu.style.top = Math.min(y, vh - mh - 8) + 'px'; + // Focus first item + const first = _ctxMenu.querySelector('[role="menuitem"]'); + if (first) setTimeout(() => first.focus(), 20); + } + function _ctxHide() { + if (_ctxMenu) _ctxMenu.classList.remove('is-open'); + } + document.addEventListener('click', () => _ctxHide()); + document.addEventListener('contextmenu', e => { + const target = e.target.closest('[data-context-menu]'); + if (!target) { _ctxHide(); return; } + e.preventDefault(); + const menuId = target.dataset.contextMenu; + const items = _ctxItems[menuId]; + if (items) _ctxShow(e.clientX, e.clientY, items); + }); + document.addEventListener('keydown', e => { if (e.key === 'Escape') _ctxHide(); }); + const contextMenu = { + register(id, items) { _ctxItems[id] = items; }, + show: (x, y, items) => _ctxShow(x, y, items), + hide: () => _ctxHide(), + }; + + /* ================================================================ + MODULE 41 — OFFLINE DETECTION + lt.offline.onOnline(fn) + lt.offline.onOffline(fn) + lt.offline.isOnline() + ================================================================ */ + let _offlineBanner = null; + function _offlineGetBanner() { + if (!_offlineBanner) { + _offlineBanner = document.getElementById('lt-offline-banner'); + if (!_offlineBanner) { + _offlineBanner = document.createElement('div'); + _offlineBanner.id = 'lt-offline-banner'; + _offlineBanner.className = 'lt-offline-banner'; + _offlineBanner.setAttribute('role', 'alert'); + _offlineBanner.setAttribute('aria-live', 'assertive'); + _offlineBanner.innerHTML = ' NO NETWORK CONNECTION — RECONNECTING...'; + document.body.appendChild(_offlineBanner); + } + } + return _offlineBanner; + } + const _offlineHandlers = []; + const _onlineHandlers = []; + window.addEventListener('offline', () => { + document.body.classList.add('lt-is-offline'); + _offlineGetBanner().classList.add('is-visible'); + toast.warning('Connection lost — working offline'); + _offlineHandlers.forEach(fn => fn()); + }); + window.addEventListener('online', () => { + document.body.classList.remove('lt-is-offline'); + _offlineGetBanner().classList.remove('is-visible'); + toast.success('Connection restored'); + _onlineHandlers.forEach(fn => fn()); + }); + const offline = { + isOnline: () => navigator.onLine, + onOffline: fn => _offlineHandlers.push(fn), + onOnline: fn => _onlineHandlers.push(fn), + }; + + /* ================================================================ + MODULE 42 — WEBSOCKET MANAGER + lt.ws.connect(url, opts) + opts: { protocols, onOpen, onMessage, onClose, onError, + reconnect: true, reconnectDelay: 2000, maxRetries: 10 } + Returns a handle: { send, close, status, on, off } + ================================================================ */ + const ws = { + connect(url, opts = {}) { + const { + protocols = [], + onOpen = null, + onMessage = null, + onClose = null, + onError = null, + reconnect = true, + reconnectDelay = 2000, + maxRetries = 10, + } = opts; + + let _sock = null; + let _retries = 0; + let _closed = false; + let _statusEl = opts.statusEl ? (typeof opts.statusEl === 'string' ? document.querySelector(opts.statusEl) : opts.statusEl) : null; + const _handlers = {}; + + function _setStatus(state) { + if (_statusEl) { + _statusEl.setAttribute('data-state', state); + _statusEl.querySelector('.lt-dot'); // force repaint + const labels = { connected: 'Connected', connecting: 'Connecting…', disconnected: 'Disconnected' }; + const dot = _statusEl.querySelector('.lt-dot'); + const span = _statusEl.querySelector('span:last-child'); + if (span) span.textContent = labels[state] || state; + } + bus.emit('ws:status', { url, state }); + } + + function _connect() { + _setStatus('connecting'); + try { _sock = protocols.length ? new WebSocket(url, protocols) : new WebSocket(url); } + catch(e) { console.error('[lt.ws] Failed to create WebSocket:', e); return; } + + _sock.addEventListener('open', e => { + _retries = 0; + _setStatus('connected'); + if (onOpen) onOpen(e); + (_handlers['open'] || []).forEach(fn => fn(e)); + }); + _sock.addEventListener('message', e => { + let data = e.data; + try { data = JSON.parse(e.data); } catch(_) {} + if (onMessage) onMessage(data, e); + (_handlers['message'] || []).forEach(fn => fn(data, e)); + }); + _sock.addEventListener('close', e => { + _setStatus('disconnected'); + if (onClose) onClose(e); + (_handlers['close'] || []).forEach(fn => fn(e)); + if (reconnect && !_closed && _retries < maxRetries) { + _retries++; + const delay = Math.min(reconnectDelay * Math.pow(1.5, _retries - 1), 30000); + setTimeout(_connect, delay); + } + }); + _sock.addEventListener('error', e => { + if (onError) onError(e); + (_handlers['error'] || []).forEach(fn => fn(e)); + }); + } + _connect(); + + return { + send(data) { + if (!_sock || _sock.readyState !== WebSocket.OPEN) { console.warn('[lt.ws] Not connected'); return false; } + _sock.send(typeof data === 'string' ? data : JSON.stringify(data)); + return true; + }, + close() { _closed = true; if (_sock) _sock.close(); _setStatus('disconnected'); }, + get status() { return _sock ? ['connecting','open','closing','closed'][_sock.readyState] : 'disconnected'; }, + on(event, fn) { if (!_handlers[event]) _handlers[event] = []; _handlers[event].push(fn); return this; }, + off(event, fn) { if (_handlers[event]) _handlers[event] = _handlers[event].filter(f => f !== fn); return this; }, + }; + }, + }; + + /* ================================================================ + MODULE 43 — MULTI-SELECT COMBOBOX + lt.combobox.init(inputEl, options, opts) + options = [{ value, label, icon? }] + opts: { max, placeholder, onChange } + ================================================================ */ + const combobox = { + init(inputEl, options = [], opts = {}) { + const wrap = inputEl.closest('.lt-combobox') || inputEl.parentElement; + const inputWrap = wrap.querySelector('.lt-combobox-input-wrap') || wrap; + const dropdown = wrap.querySelector('.lt-combobox-dropdown'); + if (!dropdown) return; + + const { max = Infinity, placeholder = 'Search…', onChange = null } = opts; + let selected = []; + let focusedIdx = -1; + let filtered = [...options]; + + function _renderTags() { + wrap.querySelectorAll('.lt-combobox-tag').forEach(t => t.remove()); + selected.forEach(v => { + const opt = options.find(o => o.value === v); + if (!opt) return; + const tag = document.createElement('span'); + tag.className = 'lt-combobox-tag'; + tag.innerHTML = `${escHtml(opt.label)}`; + inputWrap.insertBefore(tag, inputEl); + }); + } + + function _renderDropdown(query) { + const q = query.toLowerCase().trim(); + filtered = options.filter(o => !selected.includes(o.value) && (!q || o.label.toLowerCase().includes(q))); + dropdown.innerHTML = ''; + if (!filtered.length) { + dropdown.innerHTML = `
${q ? 'No matches' : 'All selected'}
`; + return; + } + filtered.forEach((opt, i) => { + const el = document.createElement('div'); + el.className = 'lt-combobox-option' + (selected.includes(opt.value) ? ' is-selected' : ''); + el.setAttribute('role', 'option'); + el.setAttribute('data-value', opt.value); + const hl = q ? opt.label.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '$1') : escHtml(opt.label); + el.innerHTML = `${opt.icon ? `${escHtml(opt.icon)}` : ''}${hl}`; + el.addEventListener('mousedown', e => { e.preventDefault(); _toggle(opt.value); }); + dropdown.appendChild(el); + }); + focusedIdx = -1; + } + + function _toggle(value) { + const idx = selected.indexOf(value); + if (idx >= 0) { selected.splice(idx, 1); } + else if (selected.length < max) { selected.push(value); } + _renderTags(); + _renderDropdown(inputEl.value); + inputEl.value = ''; + inputEl.focus(); + if (onChange) onChange([...selected]); + } + + function _moveFocus(dir) { + const items = dropdown.querySelectorAll('.lt-combobox-option'); + if (!items.length) return; + focusedIdx = Math.max(0, Math.min(focusedIdx + dir, items.length - 1)); + items.forEach((el, i) => el.classList.toggle('is-focused', i === focusedIdx)); + items[focusedIdx].scrollIntoView({ block: 'nearest' }); + } + + inputEl.addEventListener('input', () => { dropdown.classList.add('is-open'); _renderDropdown(inputEl.value); }); + inputEl.addEventListener('focus', () => { dropdown.classList.add('is-open'); _renderDropdown(inputEl.value); }); + inputEl.addEventListener('keydown', e => { + if (e.key === 'ArrowDown') { 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 === 'Escape') { dropdown.classList.remove('is-open'); } + if (e.key === 'Backspace' && !inputEl.value && selected.length) { _toggle(selected[selected.length - 1]); } + }); + inputWrap.addEventListener('mousedown', e => { + const rmBtn = e.target.closest('.lt-combobox-tag-remove'); + if (rmBtn) { e.preventDefault(); _toggle(rmBtn.dataset.value); return; } + inputEl.focus(); + }); + document.addEventListener('click', e => { if (!wrap.contains(e.target)) dropdown.classList.remove('is-open'); }); + + _renderTags(); + _renderDropdown(''); + + return { + getValue: () => [...selected], + setValue: vals => { selected = [...vals]; _renderTags(); _renderDropdown(''); }, + clear: () => { selected = []; _renderTags(); _renderDropdown(''); }, + }; + }, + }; + + /* ================================================================ + MODULE 44 — AUTOCOMPLETE / TYPEAHEAD + lt.typeahead.init(inputEl, source, opts) + source: array or async fn(query) => [{value, label, icon?, meta?}] + opts: { minChars, debounceMs, onSelect, maxResults } + ================================================================ */ + const typeahead = { + init(inputEl, source, opts = {}) { + const wrap = inputEl.closest('.lt-typeahead') || inputEl.parentElement; + const dropdown = wrap.querySelector('.lt-typeahead-dropdown'); + if (!dropdown) return; + + const { minChars = 1, debounceMs = 150, onSelect = null, maxResults = 10 } = opts; + let _focusedIdx = -1; + let _items = []; + let _debTimer = null; + + function _render(items, query) { + _items = items.slice(0, maxResults); + dropdown.innerHTML = ''; + if (!_items.length) { + dropdown.innerHTML = `
[ NO RESULTS ]
`; + return; + } + const q = query.toLowerCase(); + _items.forEach((item, i) => { + const el = document.createElement('div'); + el.className = 'lt-typeahead-item'; + el.setAttribute('role', 'option'); + const hl = item.label.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '$1'); + el.innerHTML = `${item.icon ? `${escHtml(item.icon)}` : ''}${hl}${item.meta ? `${escHtml(item.meta)}` : ''}`; + el.addEventListener('mousedown', e => { e.preventDefault(); _select(item); }); + dropdown.appendChild(el); + }); + _focusedIdx = -1; + } + + async function _search(query) { + dropdown.innerHTML = '
Searching…
'; + dropdown.classList.add('is-open'); + try { + const results = typeof source === 'function' ? await source(query) : source.filter(i => i.label.toLowerCase().includes(query.toLowerCase())); + _render(results, query); + } catch(e) { + dropdown.innerHTML = '
Error loading results
'; + } + } + + function _select(item) { + inputEl.value = item.label; + dropdown.classList.remove('is-open'); + if (onSelect) onSelect(item); + bus.emit('typeahead:select', { item }); + } + + function _moveFocus(dir) { + const els = dropdown.querySelectorAll('.lt-typeahead-item'); + if (!els.length) return; + _focusedIdx = Math.max(0, Math.min(_focusedIdx + dir, els.length - 1)); + els.forEach((el, i) => el.classList.toggle('is-focused', i === _focusedIdx)); + els[_focusedIdx].scrollIntoView({ block: 'nearest' }); + } + + inputEl.addEventListener('input', () => { + clearTimeout(_debTimer); + const q = inputEl.value.trim(); + if (q.length < minChars) { dropdown.classList.remove('is-open'); return; } + _debTimer = setTimeout(() => _search(q), debounceMs); + }); + inputEl.addEventListener('keydown', e => { + if (!dropdown.classList.contains('is-open')) return; + if (e.key === 'ArrowDown') { e.preventDefault(); _moveFocus(1); } + if (e.key === 'ArrowUp') { e.preventDefault(); _moveFocus(-1); } + if (e.key === 'Enter') { e.preventDefault(); if (_focusedIdx >= 0 && _items[_focusedIdx]) _select(_items[_focusedIdx]); } + if (e.key === 'Escape') { dropdown.classList.remove('is-open'); } + if (e.key === 'Tab') { dropdown.classList.remove('is-open'); } + }); + inputEl.addEventListener('blur', () => setTimeout(() => dropdown.classList.remove('is-open'), 150)); + document.addEventListener('click', e => { if (!wrap.contains(e.target)) dropdown.classList.remove('is-open'); }); + + return { search: q => _search(q) }; + }, + }; + + /* ================================================================ + MODULE 45 — COOKIE UTILITY + lt.cookie.set(name, value, days?, opts?) + lt.cookie.get(name) + lt.cookie.del(name) + ================================================================ */ + const cookie = { + set(name, value, days = 0, opts = {}) { + let str = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`; + if (days) { const d = new Date(); d.setDate(d.getDate() + days); str += `; expires=${d.toUTCString()}`; } + str += `; path=${opts.path || '/'}`; + if (opts.sameSite) str += `; SameSite=${opts.sameSite}`; + if (opts.secure || location.protocol === 'https:') str += '; Secure'; + document.cookie = str; + }, + get(name) { + const key = encodeURIComponent(name) + '='; + const found = document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith(key)); + return found ? decodeURIComponent(found.slice(key.length)) : null; + }, + del(name, opts = {}) { cookie.set(name, '', -1, opts); }, + getAll() { + const out = {}; + document.cookie.split(';').forEach(c => { + const [k, ...v] = c.trim().split('='); + if (k) out[decodeURIComponent(k)] = decodeURIComponent(v.join('=')); + }); + return out; + }, + }; + + /* ================================================================ + MODULE 46 — SPLIT PANE + lt.splitPane.init(containerEl, opts) + opts: { minA, minB, initial (0-1), vertical, onResize } + ================================================================ */ + const splitPane = { + init(container, opts = {}) { + const { minA = 80, minB = 80, initial = 0.5, vertical = false, onResize = null } = opts; + const panes = container.querySelectorAll('.lt-split-pane'); + const divider = container.querySelector('.lt-split-divider'); + if (!panes[0] || !panes[1] || !divider) return; + + const dim = vertical ? 'height' : 'width'; + const client = vertical ? 'clientY' : 'clientX'; + let dragging = false; + let startPos, startSizeA; + + function _setRatio(ratio) { + const total = vertical ? container.clientHeight : container.clientWidth; + const divSize = vertical ? divider.offsetHeight : divider.offsetWidth; + const available = total - divSize; + const sizeA = Math.max(minA, Math.min(available - minB, ratio * available)); + panes[0].style[dim] = sizeA + 'px'; + panes[0].style.flex = 'none'; + panes[1].style.flex = '1'; + if (onResize) onResize(sizeA, available - sizeA); + } + + // Pointer events (handles both mouse and touch) + divider.addEventListener('pointerdown', e => { + e.preventDefault(); + dragging = true; + divider.setPointerCapture(e.pointerId); + divider.classList.add('is-dragging'); + startPos = e[client]; + startSizeA = vertical ? panes[0].offsetHeight : panes[0].offsetWidth; + }); + divider.addEventListener('pointermove', e => { + if (!dragging) return; + const delta = e[client] - startPos; + const total = vertical ? container.clientHeight : container.clientWidth; + const divSize = vertical ? divider.offsetHeight : divider.offsetWidth; + const newSize = Math.max(minA, Math.min(total - divSize - minB, startSizeA + delta)); + panes[0].style[dim] = newSize + 'px'; + panes[0].style.flex = 'none'; + panes[1].style.flex = '1'; + if (onResize) onResize(newSize, total - divSize - newSize); + }); + divider.addEventListener('pointerup', () => { dragging = false; divider.classList.remove('is-dragging'); }); + + _setRatio(initial); + return { setRatio: _setRatio }; + }, + }; + + /* ================================================================ + MODULE 47 — TOAST QUEUE (enhanced dispatch) + Wraps existing toast module with max-stack + progress bars + ================================================================ */ + const _toastMaxStack = 5; + const _toastQueue = []; + let _toastActive = 0; + const _origToast = Object.assign({}, toast); + + function _toastEnqueue(type, msg, dur = 4000) { + if (_toastActive >= _toastMaxStack) { + _toastQueue.push({ type, msg, dur }); + return; + } + _toastActive++; + const id = _origToast[type] ? _origToast[type](msg, dur) : _origToast.info(msg, dur); + // Add progress bar to the toast element + setTimeout(() => { + const toastEl = document.querySelector(`#lt-toast-container .lt-toast:last-child`); + if (toastEl && !toastEl.querySelector('.lt-toast-progress')) { + const bar = document.createElement('div'); + bar.className = 'lt-toast-progress'; + bar.style.animationDuration = dur + 'ms'; + toastEl.appendChild(bar); + } + }, 20); + setTimeout(() => { + _toastActive = Math.max(0, _toastActive - 1); + if (_toastQueue.length) { + const next = _toastQueue.shift(); + _toastEnqueue(next.type, next.msg, next.dur); + } + }, dur + 400); + return id; + } + + // Override toast methods to use queue + ['success','error','warning','info'].forEach(t => { + if (toast[t]) { + const orig = toast[t].bind(toast); + toast[t] = (msg, dur) => _toastEnqueue(t, msg, dur || 4000); + } + }); + + /* ================================================================ PUBLIC API ---------------------------------------------------------------- */ global.lt = { @@ -1491,6 +2114,17 @@ /* v1.3 responsive */ viewport, mobileNav, + /* v1.1 new features */ + theme, + notif, + rightDrawer, + contextMenu, + offline, + ws, + combobox, + typeahead, + cookie, + splitPane, }; }(window));