Compare commits

..

12 Commits

Author SHA1 Message Date
jared 80011e6de5 docs: update README for LotusGuild Terminal Design System v1.2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 14:32:58 -04:00
jared d651cfbe2c audit pass 15: type=button on JS createElement and innerHTML buttons
- Toast close button: set closeEl.type = 'button' on createElement
- Combobox tag remove button: add type="button" to innerHTML string

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 14:24:10 -04:00
jared bdf3ad085f audit pass 14: type=button on JS-generated button HTML strings
Add type="button" to all buttons created via innerHTML in JS:
- Lightbox close/prev/next buttons (3 instances)
- Pagination prev/page/ellipsis/next buttons (7 instances)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 14:22:11 -04:00
jared 5caaf38e9a audit pass 13: context menu arrow key navigation
Add ArrowUp/ArrowDown/Home/End keyboard navigation between context
menu items per WAI-ARIA menu widget specification. Focus wraps at
top and bottom boundaries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 14:18:17 -04:00
jared 7630da8abd fix: boot sequence lines appearing twice
Two init paths both called runBoot() before sessionStorage was set
(the key was only written on completion, ~1.1s later):
- Private init() at DOMContentLoaded → runBoot()
- lt.init() HTML call on DOMContentLoaded → ltInit() → runBoot()

Both found no session key and started simultaneous setInterval loops
on the same <pre>, each appending the same message → every line twice.

Fix: set the sessionStorage key immediately at the start of runBoot()
so any concurrent second call exits early.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 14:12:05 -04:00
jared 45b968b77d audit pass 12: type=button, focus restore, :focus-visible on links
HTML:
- Add type="button" to all remaining buttons (nav drawer close, menu
  btn, theme toggle, notif bell, right drawer close, tab buttons,
  sidebar toggle, alert close x4, code copy, tab bar buttons x4,
  detail panel open, modal close x2, keyboard shortcuts close)
- Add aria-label="Search commands" to command palette input
- Notification panel close(true): restore focus to bell on Escape
- Generic dropdowns: add Escape key handler with trigger focus restore

CSS:
- Add a:focus-visible global focus ring
- Add .lt-nav-dropdown-menu li a:focus-visible
- Add .lt-markdown a:focus-visible
- Fix dead .lt-typeahead-option selector → .lt-typeahead-item with
  :hover, .is-focused, :focus-visible for light theme

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 13:49:23 -04:00
jared ca2d6d225e audit pass 10-11: type=button, XSS escaping, focus/ARIA fixes
HTML:
- Add type="button" to all buttons outside forms (22 instances)
- Add aria-label="Add comment" to unlabelled textarea#td-comment

JS:
- Escape alt text and link text in markdown renderer with escHtml()
  to prevent XSS in image alt/link content
- Fix nested modal focus: only restore trigger focus when no other
  modal is still open; add document.contains guard

CSS:
- Add .lt-nav-link:focus-visible focus ring (was missing entirely)
- Fix .lt-typeahead-option (dead selector) → .lt-typeahead-item with
  :hover, .is-focused, and :focus-visible for light theme

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 20:46:31 -04:00
jared 8b54efef61 audit pass 9: XSS fix, focus management, ARIA labels, and :focus-visible gaps
JS:
- Lightbox: remove keydown listener on close (memory leak fix)
- Lightbox: restore focus to trigger on close
- Right drawer: fix aria-hidden="false" anti-pattern to removeAttribute
- Markdown renderer: block javascript:/data: protocol URIs in link and
  image replacements to prevent XSS
- Sidebar submenus: add aria-expanded tracking on toggle; hide decorative
  chevron from screen readers; initialize aria-expanded on mount

CSS:
- Add :focus-visible to .lt-sidebar-toggle (interactive button)
- Add :focus-visible to .lt-dropzone (focusable container)
- Fix .lt-stat-card:focus-visible outline-offset to -2px (clip-path clips it)
- Add light theme override for .lt-nav-drawer-link:focus-visible
- Adjust .lt-split-divider:focus-visible outline-offset to 3px

HTML:
- Range input: update aria-valuenow dynamically on input event
- Combobox label: add for="demo-combobox-input" association
- Typeahead label: add for="demo-typeahead-input" association
- Dropzone file input: add aria-label
- Notification items: add descriptive aria-label to all 4 items;
  add aria-hidden="true" to decorative dot spans
- Mark all read button: add type="button" to prevent accidental form submit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 20:16:12 -04:00
jared e8c1197613 audit pass 8: keyboard accessibility and table semantics
CSS:
- Fix light theme input/select/textarea :focus -> :focus-visible

HTML:
- Worker metrics table: convert label <td> to <th scope="row"> for screen readers
- Add aria-label to worker metrics table
- Sticky table: add scope="col" to all column headers
- Keyboard shortcuts modal table: add scope="col" to headers
- Kanban cards: remove tabindex="0" from role="article" (non-interactive)
- Advanced filter: ensure all 3 label/select pairs have for/id associations

JS:
- Lightbox: fix keydown listener leak by storing bound reference for removeEventListener
- Lightbox: save/restore trigger focus on open/close
- Sortable table: add tabindex="0" and Enter/Space keydown handler on sortable <th>
- Split pane: add tabindex="0", role="separator", aria-label, and arrow/Home/End
  keyboard resize support on divider (5% steps)
- Form validation: handle <select multiple> required check via selectedOptions.length

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 20:09:29 -04:00
jared b84d71dd7a audit pass 7: ARIA, focus management, and label fixes
CSS:
- Add :focus-visible to sortable th, breadcrumb links, list links, cmd-item,
  combobox-option, typeahead-item, sidebar-sub-link, split-divider, stat-card
- Fix .lt-skip-link:focus to also include :focus-visible for spec compliance

JS:
- Mobile nav: add focus trap (_trapFocus), save/restore trigger focus, fix
  aria-hidden="false" to removeAttribute pattern, add document.contains guard
- Combobox: add aria-activedescendant on _moveFocus; add unique IDs to options;
  clear aria-activedescendant on close; wrap querySelectorAll in Array.from
- Typeahead: add aria-activedescendant on _moveFocus; add unique IDs to items;
  add aria-busy during async search; clear aria-activedescendant on select;
  wrap querySelectorAll in Array.from
- Command palette: add unique IDs to items; set/clear aria-activedescendant
  on move and mouseenter; clear on close
- Lightbox: add document.contains guard on focus setTimeout
- Stats filter: add Enter/Space keyboard handler for role="button" cards

HTML:
- Stat cards: add role="button" tabindex="0" aria-label (interactive divs)
- Advanced filter selects: add id/for associations to all 3 label+select pairs
- Accordion SVG icons: add aria-hidden="true" (decorative)
- Range input: add aria-label, aria-valuemin/max/now
- Wizard form controls: add id/for to all 4 label+input/select pairs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 20:02:15 -04:00
jared d08007cdd7 audit pass 6: accessibility, ARIA, and keyboard fixes
- JS: fix checkbox/radio required validation using .checked not .value
- JS: guard _cpTrigger.focus() with document.contains() check
- JS: add arrow/Home/End key navigation to tab groups (WCAG 2.1)
- JS: clamp context menu left edge with Math.max(8, ...) to prevent off-screen
- JS: fix wizard _show() to removeAttribute aria-hidden on active step
- HTML: add role="region" + aria-label to notification panel
- HTML: convert Assigned To span+div to label+select with for/id association
- HTML: add role="article" tabindex="0" aria-label to all kanban cards
- HTML: remove aria-hidden="false" anti-pattern from wizard active step
- CSS/HTML/JS: replace aria-hidden="false" show-hook with :not([aria-hidden])
  so open state is represented by absent attribute rather than false value

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 19:51:21 -04:00
jared fdcadad23b fix: accessibility & quality audit pass 4+5
CSS:
- Add :active/:focus-visible to .lt-modal-close, .lt-drawer-right-close,
  .lt-notif-panel-clear, .lt-file-item-remove
- Add :focus-visible to .lt-accordion-header, .lt-tag-remove,
  .lt-combobox-tag-remove
- Add .lt-cmd-input-wrap:focus-within focus indicator (outline:none compensation)
- Add will-change: stroke-dashoffset to .lt-gauge-fill
- Add range slider :focus-visible thumb ring
- Fix .tok-cmt hardcoded #5c8c6a → var(--color-tok-cmt) w/ light-mode override
- Add .lt-skip-link component (visible on focus)
- Fix .lt-filter-group fieldset UA border reset

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 18:22:53 -04:00
4 changed files with 1587 additions and 790 deletions
+834 -482
View File
File diff suppressed because it is too large Load Diff
+266 -28
View File
@@ -175,7 +175,16 @@
--z-popover: 10012;
--z-tooltip: 10013;
--z-toast: 10014;
--z-panel: 10020; /* notification / generic dropdown panels — above everything */
--z-overlay: 9999; /* scanlines / CRT effects */
/* --- Corner cuts (clip-path polygon notches) --- */
--corner-cut: 8px;
--corner-cut-sm: 5px;
--corner-cut-lg: 16px;
/* --- Syntax highlight --- */
--color-tok-cmt: #5c8c6a;
}
@@ -218,6 +227,10 @@ a:hover {
color: var(--accent-orange);
text-shadow: var(--glow-orange);
}
a:focus-visible {
outline: 2px solid var(--accent-cyan);
outline-offset: 2px;
}
ul, ol { list-style: none; }
img, svg { display: block; max-width: 100%; }
@@ -478,6 +491,7 @@ hr {
background: var(--accent-cyan-dim);
}
.lt-nav-link:hover::after { left: 0; right: 0; box-shadow: var(--glow-cyan); }
.lt-nav-link:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: -2px; color: var(--accent-cyan); }
.lt-nav-link.active {
color: var(--accent-orange);
@@ -533,6 +547,11 @@ hr {
text-shadow: var(--glow-orange);
padding-left: 1.1rem;
}
.lt-nav-dropdown-menu li a:focus-visible {
outline: 2px solid var(--accent-cyan);
outline-offset: -2px;
color: var(--accent-cyan);
}
/* Header user + admin badge */
.lt-header-user {
@@ -763,6 +782,12 @@ hr {
.lt-btn:active { transform: translateY(1px); }
.lt-btn:focus-visible {
outline: 2px solid var(--accent-cyan);
outline-offset: 2px;
box-shadow: var(--box-glow-cyan);
}
.lt-btn:disabled,
.lt-btn[disabled] {
opacity: 0.30;
@@ -859,10 +884,27 @@ hr {
.lt-input:focus,
.lt-select:focus,
.lt-textarea:focus {
.lt-textarea:focus,
.lt-input:focus-visible,
.lt-select:focus-visible,
.lt-textarea:focus-visible {
border-color: var(--accent-cyan);
box-shadow: var(--box-glow-cyan);
background: rgba(0,212,255,0.025);
outline: 2px solid var(--accent-cyan);
outline-offset: 1px;
}
.lt-input:disabled,
.lt-select:disabled,
.lt-textarea:disabled,
.lt-input[readonly],
.lt-textarea[readonly] {
opacity: 0.45;
cursor: not-allowed;
background: var(--bg-tertiary);
color: var(--text-muted);
border-color: var(--border-dim);
}
.lt-input::placeholder,
@@ -949,6 +991,47 @@ select option:checked {
text-shadow: var(--glow-cyan);
}
.lt-checkbox:hover { border-color: var(--accent-cyan); }
.lt-checkbox:focus-visible {
outline: 2px solid var(--accent-cyan);
outline-offset: 2px;
}
.lt-checkbox:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Radio (same visual language as checkbox) */
.lt-radio {
appearance: none;
width: 14px;
height: 14px;
border: 1px solid var(--accent-cyan-border);
border-radius: 50%;
background: var(--bg-terminal);
cursor: pointer;
flex-shrink: 0;
vertical-align: middle;
transition: var(--transition-fast);
position: relative;
}
.lt-radio:checked {
border-color: var(--accent-cyan);
background: var(--accent-cyan-dim);
}
.lt-radio:checked::after {
content: '';
position: absolute;
inset: 3px;
border-radius: 50%;
background: var(--accent-cyan);
box-shadow: var(--glow-cyan);
}
.lt-radio:hover { border-color: var(--accent-cyan); }
.lt-radio:focus-visible {
outline: 2px solid var(--accent-cyan);
outline-offset: 2px;
}
.lt-radio:disabled { opacity: 0.4; cursor: not-allowed; }
/* ----------------------------------------------------------------
@@ -985,6 +1068,7 @@ select option:checked {
color: var(--accent-orange);
text-shadow: var(--glow-orange);
}
.lt-table th[data-sort-key]:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: -2px; }
.lt-table td {
padding: 0.55rem 0.85rem;
@@ -1055,7 +1139,7 @@ select option:checked {
.lt-status-closed { color: var(--status-closed); background: rgba(255,45,85,0.07); }
.lt-status-online { color: var(--status-online); background: rgba(0,255,136,0.07); text-shadow: var(--glow-green); }
.lt-status-offline { color: var(--status-offline); background: rgba(255,45,85,0.07); }
.lt-status-running { color: var(--status-running); background: rgba(255,179,0,0.07); animation: pulse-amber 2s infinite; }
.lt-status-running { color: var(--status-running); background: rgba(255,179,0,0.07); animation: pulse-amber 2s infinite; will-change: color; }
.lt-status-completed { color: var(--status-completed); background: rgba(0,212,255,0.07); }
.lt-status-failed { color: var(--status-failed); background: rgba(255,45,85,0.07); }
@@ -1107,6 +1191,17 @@ select option:checked {
.lt-badge-amber { color: var(--accent-amber); }
.lt-badge-red { color: var(--accent-red); }
/* Status + priority badge variants (dark-mode base) */
.lt-badge-open { color: var(--accent-green); background: rgba(0,255,136,0.08); border-color: rgba(0,255,136,0.35); text-shadow: var(--glow-green); }
.lt-badge-closed { color: var(--text-muted); background: rgba(74,90,112,0.10); border-color: rgba(74,90,112,0.35); }
.lt-badge-in-progress,
.lt-badge-progress { color: var(--accent-amber); background: rgba(255,179,0,0.08); border-color: rgba(255,179,0,0.35); }
.lt-badge-pending { color: var(--accent-purple); background: rgba(191,95,255,0.08); border-color: rgba(191,95,255,0.35); }
.lt-badge-p1 { color: var(--accent-red); background: rgba(255,45,85,0.09); border-color: rgba(255,45,85,0.40); text-shadow: var(--glow-red); }
.lt-badge-p2 { color: var(--accent-orange); background: rgba(255,107,0,0.09); border-color: rgba(255,107,0,0.38); }
.lt-badge-p3 { color: var(--accent-cyan); background: rgba(0,212,255,0.07); border-color: rgba(0,212,255,0.30); }
.lt-badge-p4 { color: var(--accent-green); background: rgba(0,255,136,0.07); border-color: rgba(0,255,136,0.30); }
/* Status dots */
.lt-dot {
display: inline-block;
@@ -1115,9 +1210,9 @@ select option:checked {
border-radius: 50%;
flex-shrink: 0;
}
.lt-dot-up { background: var(--accent-green); box-shadow: 0 0 6px var(--accent-green), 0 0 14px var(--accent-green); animation: pulse-green 2.2s ease-in-out infinite; }
.lt-dot-up { background: var(--accent-green); box-shadow: 0 0 6px var(--accent-green), 0 0 14px var(--accent-green); animation: pulse-green 2.2s ease-in-out infinite; will-change: box-shadow; }
.lt-dot-down { background: var(--accent-red); box-shadow: 0 0 6px var(--accent-red), 0 0 12px var(--accent-red); }
.lt-dot-warn { background: var(--accent-amber); box-shadow: 0 0 6px var(--accent-amber), 0 0 12px var(--accent-amber); animation: pulse-amber 2.2s ease-in-out infinite; }
.lt-dot-warn { background: var(--accent-amber); box-shadow: 0 0 6px var(--accent-amber), 0 0 12px var(--accent-amber); animation: pulse-amber 2.2s ease-in-out infinite; will-change: box-shadow; }
.lt-dot-idle { background: var(--text-muted); box-shadow: none; }
@@ -1194,6 +1289,8 @@ select option:checked {
transition: var(--transition-fast);
}
.lt-modal-close:hover { color: var(--accent-red); text-shadow: var(--glow-red); }
.lt-modal-close:active { color: var(--accent-red); opacity: 0.7; }
.lt-modal-close:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; border-radius: 2px; }
.lt-modal-body {
padding: var(--space-lg);
@@ -1292,6 +1389,10 @@ select option:checked {
border-color: var(--accent-orange-border);
border-bottom-color: var(--bg-card);
}
.lt-tab:focus-visible {
outline: 2px solid var(--accent-orange);
outline-offset: -2px;
}
.lt-tab-panel { display: none; }
.lt-tab-panel.active { display: block; }
@@ -1334,13 +1435,16 @@ select option:checked {
transition: var(--transition-fast);
}
.lt-sidebar-toggle:hover { color: var(--accent-cyan); text-shadow: var(--glow-cyan); }
.lt-sidebar-toggle:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; border-radius: 2px; }
.lt-sidebar-body { padding: var(--space-md); }
.lt-filter-group {
margin-bottom: var(--space-md);
padding-bottom: var(--space-md);
padding: 0 0 var(--space-md) 0;
border: none;
border-bottom: 1px solid var(--border-color-dim);
min-width: 0; /* fieldset UA reset */
}
.lt-filter-group:last-child { border-bottom: none; }
@@ -1403,11 +1507,13 @@ select option:checked {
}
.lt-stat-card:hover,
.lt-stat-card.active {
.lt-stat-card.active,
.lt-stat-card:focus-visible {
background: var(--bg-tertiary);
border-color: var(--accent-cyan-border);
box-shadow: var(--box-glow-cyan);
}
.lt-stat-card:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: -2px; }
.lt-stat-card:hover::before,
.lt-stat-card.active::before { height: 100%; }
@@ -1700,6 +1806,7 @@ select option:checked {
transition: transform 0.2s ease, opacity 0.2s ease;
box-shadow: var(--glow-cyan);
}
.lt-menu-btn:active { opacity: 0.7; }
.lt-menu-btn:focus-visible { outline: 1px solid var(--accent-cyan); outline-offset: 2px; }
.lt-menu-btn.open span:nth-child(1) { transform: translateY(6px) rotate(45deg); }
.lt-menu-btn.open span:nth-child(2) { opacity: 0; }
@@ -2195,7 +2302,35 @@ select option:checked {
.lt-text-upper { text-transform: uppercase; letter-spacing: 0.1em; }
.lt-hidden { display: none !important; }
.lt-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
/* Skip navigation link — visible only on focus */
.lt-skip-link {
position: absolute;
top: -100%;
left: var(--space-sm);
padding: var(--space-xs) var(--space-md);
background: var(--bg-terminal);
color: var(--accent-cyan);
border: 1px solid var(--accent-cyan);
font-family: var(--font-mono);
font-size: 0.8rem;
text-decoration: none;
text-transform: uppercase;
letter-spacing: 0.05em;
z-index: var(--z-toast);
transition: top 0.1s;
}
.lt-skip-link:focus, .lt-skip-link:focus-visible { top: var(--space-sm); }
.lt-sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip-path: inset(50%); /* replaces deprecated clip: rect(0,0,0,0) */
white-space: nowrap;
border: 0;
}
/* Cursor blink */
.lt-cursor::after {
@@ -2206,6 +2341,9 @@ select option:checked {
font-size: 0.85em;
margin-left: 1px;
}
@media (prefers-reduced-motion: reduce) {
.lt-cursor::after { animation: none; }
}
/* Glitch text effect */
.lt-glitch { position: relative; }
@@ -2221,11 +2359,13 @@ select option:checked {
color: var(--accent-cyan);
opacity: 0.65;
animation: glitch-1 4s infinite;
will-change: clip-path, transform;
}
.lt-glitch::after {
color: var(--accent-orange);
opacity: 0.65;
animation: glitch-2 4s 0.12s infinite;
will-change: clip-path, transform;
}
@@ -2347,7 +2487,9 @@ select option:checked {
transform: translateX(-50%) translateY(4px);
}
[data-tooltip]:hover::before,
[data-tooltip]:hover::after {
[data-tooltip]:focus-visible::before,
[data-tooltip]:hover::after,
[data-tooltip]:focus-visible::after {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
@@ -2365,7 +2507,9 @@ select option:checked {
transform: translateX(-50%) translateY(-4px);
}
[data-tooltip][data-tooltip-pos="bottom"]:hover::before,
[data-tooltip][data-tooltip-pos="bottom"]:hover::after {
[data-tooltip][data-tooltip-pos="bottom"]:focus-visible::before,
[data-tooltip][data-tooltip-pos="bottom"]:hover::after,
[data-tooltip][data-tooltip-pos="bottom"]:focus-visible::after {
transform: translateX(-50%) translateY(0);
}
@@ -2389,6 +2533,7 @@ select option:checked {
transition: color 0.15s;
}
.lt-breadcrumb-item a:hover { color: var(--accent-cyan); }
.lt-breadcrumb-item a:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; border-radius: 2px; }
.lt-breadcrumb-item.active { color: var(--accent-orange); }
.lt-breadcrumb-sep { color: var(--border-dim); }
.lt-breadcrumb-sep::before { content: '/'; }
@@ -2426,6 +2571,12 @@ select option:checked {
box-shadow: var(--glow-orange);
font-weight: 700;
}
.lt-page-btn:focus-visible {
outline: 2px solid var(--accent-cyan);
outline-offset: 1px;
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.lt-page-btn:disabled,
.lt-page-btn[aria-disabled="true"] {
opacity: 0.35;
@@ -2459,6 +2610,8 @@ select option:checked {
text-align: left;
}
.lt-accordion-header:hover { background: var(--bg-tertiary); color: var(--accent-cyan); }
.lt-accordion-header:active { background: var(--bg-tertiary); opacity: 0.8; }
.lt-accordion-header:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: -2px; }
.lt-accordion-header[aria-expanded="true"] { color: var(--accent-orange); }
.lt-accordion-icon {
width: 14px;
@@ -2515,6 +2668,7 @@ select option:checked {
transition: color 0.15s;
}
.lt-alert-close:hover { color: var(--accent-red); }
.lt-alert-close:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; border-radius: 2px; }
.lt-alert.dismissed { max-height: 0 !important; opacity: 0; padding-top: 0; padding-bottom: 0; pointer-events: none; }
@@ -2529,7 +2683,21 @@ select option:checked {
font-family: var(--font-mono);
font-size: 0.8rem;
}
.lt-toggle input { display: none; }
/* Visually hidden but still keyboard-focusable */
.lt-toggle input {
position: absolute;
opacity: 0;
width: 1px; height: 1px;
margin: -1px; padding: 0;
border: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.lt-toggle input:focus-visible ~ .lt-toggle-track {
outline: 2px solid var(--accent-cyan);
outline-offset: 2px;
}
.lt-toggle-track {
width: 36px;
height: 18px;
@@ -2573,9 +2741,17 @@ input[type="range"].lt-range {
height: 4px;
background: var(--bg-tertiary);
border: 1px solid var(--border-dim);
outline: none;
outline: none; /* reset; :focus-visible below provides keyboard ring */
cursor: pointer;
}
input[type="range"].lt-range:focus-visible {
outline: 2px solid var(--accent-orange);
outline-offset: 4px;
}
input[type="range"].lt-range:disabled {
opacity: 0.4;
cursor: not-allowed;
}
input[type="range"].lt-range::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px; height: 14px;
@@ -2586,6 +2762,7 @@ input[type="range"].lt-range::-webkit-slider-thumb {
transition: transform 0.15s;
}
input[type="range"].lt-range::-webkit-slider-thumb:hover { transform: scale(1.3); }
input[type="range"].lt-range:focus-visible::-webkit-slider-thumb { transform: scale(1.3); box-shadow: 0 0 0 3px var(--bg-primary), 0 0 0 5px var(--accent-cyan); }
input[type="range"].lt-range::-moz-range-thumb {
width: 14px; height: 14px;
background: var(--accent-orange);
@@ -2608,6 +2785,7 @@ input[type="range"].lt-range::-moz-range-thumb {
position: relative;
clip-path: polygon(0 0, calc(100% - 16px) 0, 100% 16px, 100% 100%, 16px 100%, 0 calc(100% - 16px));
}
.lt-dropzone:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: -2px; }
.lt-dropzone:hover,
.lt-dropzone.drag-over {
border-color: var(--accent-cyan);
@@ -2638,6 +2816,8 @@ input[type="range"].lt-range::-moz-range-thumb {
.lt-file-item-name { color: var(--text-secondary); }
.lt-file-item-size { color: var(--text-dim); }
.lt-file-item-remove { background: none; border: none; color: var(--accent-red); cursor: pointer; padding: 0; font-size: 0.9rem; }
.lt-file-item-remove:hover { opacity: 0.75; text-shadow: var(--glow-red); }
.lt-file-item-remove:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; border-radius: 2px; }
/* ----------------------------------------------------------------
@@ -2681,6 +2861,7 @@ input[type="range"].lt-range::-moz-range-thumb {
font-size: 0.9rem;
caret-color: var(--accent-orange);
}
.lt-cmd-input-wrap:focus-within { border-bottom-color: var(--accent-cyan); }
.lt-cmd-results { max-height: 320px; overflow-y: auto; }
.lt-cmd-group-label {
padding: var(--space-xs) var(--space-md);
@@ -2702,7 +2883,8 @@ input[type="range"].lt-range::-moz-range-thumb {
transition: background 0.1s;
}
.lt-cmd-item:hover,
.lt-cmd-item.is-selected {
.lt-cmd-item.is-selected,
.lt-cmd-item:focus-visible {
background: rgba(0,212,255,0.08);
color: var(--accent-cyan);
}
@@ -2771,6 +2953,7 @@ input[type="range"].lt-range::-moz-range-thumb {
transition: all 0.15s;
}
.lt-code-copy:hover { border-color: var(--accent-cyan); color: var(--accent-cyan); }
.lt-code-copy:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; }
.lt-code-copy.copied { border-color: var(--accent-green); color: var(--accent-green); }
.lt-code-block pre {
margin: 0;
@@ -2788,7 +2971,7 @@ input[type="range"].lt-range::-moz-range-thumb {
.tok-kw { color: var(--accent-cyan); }
.tok-str { color: var(--accent-green); }
.tok-num { color: var(--accent-orange); }
.tok-cmt { color: #5c8c6a; font-style: italic; }
.tok-cmt { color: var(--color-tok-cmt); font-style: italic; }
.tok-fn { color: var(--accent-purple); }
@@ -2827,6 +3010,7 @@ input[type="range"].lt-range::-moz-range-thumb {
line-height: 1;
}
.lt-tag-remove:hover { opacity: 1; }
.lt-tag-remove:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 1px; border-radius: 2px; opacity: 1; }
/* ----------------------------------------------------------------
@@ -2948,6 +3132,7 @@ input[type="range"].lt-range::-moz-range-thumb {
flex: 1;
}
.lt-list-item a:hover { color: var(--accent-cyan); }
.lt-list-item a:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; border-radius: 2px; }
.lt-list-item-meta { color: var(--text-dim); font-size: 0.7rem; margin-left: auto; }
.lt-list-item--active { background: rgba(255,107,0,0.06); border-left: 2px solid var(--accent-orange); }
@@ -3113,7 +3298,8 @@ input[type="range"].lt-range::-moz-range-thumb {
.lt-gauge svg { overflow: visible; }
.lt-gauge-track { fill: none; stroke: var(--bg-tertiary); stroke-width: 8; }
.lt-gauge-fill { fill: none; stroke: var(--accent-orange); stroke-width: 8; stroke-linecap: butt;
transition: stroke-dashoffset 0.6s cubic-bezier(0.4,0,0.2,1); }
transition: stroke-dashoffset 0.6s cubic-bezier(0.4,0,0.2,1);
will-change: stroke-dashoffset; }
.lt-gauge-label {
position: absolute;
bottom: 0; left: 50%;
@@ -3138,6 +3324,7 @@ input[type="range"].lt-range::-moz-range-thumb {
border-top-color: var(--accent-orange);
border-radius: 50%;
animation: lt-spin 0.7s linear infinite;
will-change: transform;
}
.lt-spinner--cyan { border-top-color: var(--accent-cyan); }
.lt-spinner--green { border-top-color: var(--accent-green); }
@@ -3247,7 +3434,6 @@ input[type="range"].lt-range::-moz-range-thumb {
.lt-border-red { border-color: var(--accent-red) !important; }
/* Display */
.lt-hidden { display: none !important; }
.lt-visible { display: block !important; }
.lt-flex { display: flex; }
.lt-grid { display: grid; }
@@ -3404,6 +3590,9 @@ html[data-theme="light"] {
--box-glow-red: 0 0 0 2px rgba(181,0,31,0.22), 0 2px 8px rgba(181,0,31,0.12);
--box-glow-amber: 0 0 0 2px rgba(138,90,0,0.22), 0 2px 8px rgba(138,90,0,0.12);
/* — Syntax highlight — */
--color-tok-cmt: #2e6540;
color-scheme: light;
}
@@ -3442,6 +3631,7 @@ html[data-theme="light"] .lt-nav-link.active { color: var(--accent-orange
html[data-theme="light"] .lt-nav-drawer-link { color: var(--text-secondary); }
html[data-theme="light"] .lt-nav-drawer-link:hover { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
html[data-theme="light"] .lt-nav-drawer-link.active { color: var(--accent-orange); background: var(--accent-orange-dim); }
html[data-theme="light"] .lt-nav-drawer-link:focus-visible { outline: none; color: var(--accent-cyan); background: var(--accent-cyan-dim); box-shadow: inset 3px 0 0 var(--accent-cyan); }
html[data-theme="light"] .lt-sidebar-nav-link { color: var(--text-secondary); }
html[data-theme="light"] .lt-sidebar-nav-link:hover { background: var(--accent-cyan-dim); }
html[data-theme="light"] .lt-sidebar-nav-link.active { background: var(--accent-orange-dim); color: var(--accent-orange); }
@@ -3472,9 +3662,9 @@ html[data-theme="light"] .lt-textarea {
color: var(--text-primary);
box-shadow: inset 0 1px 3px rgba(0,0,0,0.05);
}
html[data-theme="light"] .lt-input:focus,
html[data-theme="light"] .lt-select:focus,
html[data-theme="light"] .lt-textarea:focus {
html[data-theme="light"] .lt-input:focus-visible,
html[data-theme="light"] .lt-select:focus-visible,
html[data-theme="light"] .lt-textarea:focus-visible {
border-color: var(--accent-cyan);
box-shadow: var(--box-glow-cyan);
}
@@ -3506,7 +3696,10 @@ html[data-theme="light"] .lt-table-wrap { border-color: var(--border-color);
html[data-theme="light"] .lt-badge { background: var(--bg-tertiary); color: var(--text-secondary); }
html[data-theme="light"] .lt-badge-open { background: rgba(0,109,53,0.12); color: #006d35; }
html[data-theme="light"] .lt-badge-closed { background: rgba(90,110,150,0.12); color: #5a6e8c; }
html[data-theme="light"] .lt-badge-p1 { background: rgba(181,0,31,0.12); color: #b5001f; }
html[data-theme="light"] .lt-badge-in-progress,
html[data-theme="light"] .lt-badge-progress { background: rgba(138,90,0,0.12); color: #8a5a00; text-shadow: none; }
html[data-theme="light"] .lt-badge-pending { background: rgba(107,47,184,0.10); color: #7c22cc; }
html[data-theme="light"] .lt-badge-p1 { background: rgba(181,0,31,0.12); color: #b5001f; text-shadow: none; }
html[data-theme="light"] .lt-badge-p2 { background: rgba(196,78,0,0.12); color: #c44e00; }
html[data-theme="light"] .lt-badge-p3 { background: rgba(138,90,0,0.12); color: #8a5a00; }
html[data-theme="light"] .lt-badge-p4 { background: rgba(50,80,130,0.10); color: #2d3d56; }
@@ -3614,8 +3807,8 @@ html[data-theme="light"] .lt-divider-label { color: var(--text-dim); }
html[data-theme="light"] .lt-tag { background: var(--bg-tertiary); border-color: var(--border-color); color: var(--text-secondary); }
/* — Tooltips — */
html[data-theme="light"] [data-tooltip]::before { background: #1a2035; color: #fff; }
html[data-theme="light"] [data-tooltip]::after { border-top-color: #1a2035; }
html[data-theme="light"] [data-tooltip]::before { background: var(--bg-terminal); color: var(--text-primary); }
html[data-theme="light"] [data-tooltip]::after { border-top-color: var(--bg-terminal); }
/* — Pagination — */
html[data-theme="light"] .lt-page-btn { background: var(--bg-card); border-color: var(--border-color); color: var(--text-secondary); }
@@ -3701,7 +3894,9 @@ html[data-theme="light"] .lt-empty-state-title { color: var(--text-secondary); }
html[data-theme="light"] .lt-combobox-dropdown,
html[data-theme="light"] .lt-typeahead-dropdown { background: var(--bg-card); border-color: var(--border-color); box-shadow: 0 4px 16px rgba(0,0,0,0.1); }
html[data-theme="light"] .lt-combobox-option:hover,
html[data-theme="light"] .lt-typeahead-option:hover { background: var(--accent-cyan-dim); }
html[data-theme="light"] .lt-typeahead-item:hover,
html[data-theme="light"] .lt-typeahead-item.is-focused,
html[data-theme="light"] .lt-typeahead-item:focus-visible { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
html[data-theme="light"] .lt-combobox-tag { background: var(--accent-cyan-dim); color: var(--accent-cyan); border-color: var(--accent-cyan-border); }
/* — Sortable ghost — */
@@ -3766,6 +3961,7 @@ html[data-theme="light"] ::-webkit-scrollbar-thumb:hover { background: var(--acc
animation: lt-shimmer 1.6s ease-in-out infinite;
border-radius: 2px;
display: block;
will-change: opacity;
}
@keyframes lt-shimmer {
0% { background-position: 200% 0; }
@@ -3942,6 +4138,7 @@ html[data-theme="light"] ::-webkit-scrollbar-thumb:hover { background: var(--acc
transition: var(--transition-fast);
}
.lt-drawer-right-close:hover { color: var(--accent-red); border-color: var(--accent-red); }
.lt-drawer-right-close:active { color: var(--accent-red); opacity: 0.7; }
.lt-drawer-right-close:focus-visible { outline: 1px solid var(--accent-cyan); outline-offset: 2px; }
.lt-drawer-right-body {
flex: 1;
@@ -4056,6 +4253,7 @@ html[data-theme="light"] ::-webkit-scrollbar-thumb:hover { background: var(--acc
display: flex; align-items: center;
}
.lt-combobox-tag-remove:hover { color: var(--accent-red); }
.lt-combobox-tag-remove:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 1px; border-radius: 2px; }
/* Dropdown list */
.lt-combobox-dropdown {
position: absolute;
@@ -4081,7 +4279,8 @@ html[data-theme="light"] ::-webkit-scrollbar-thumb:hover { background: var(--acc
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-focused,
.lt-combobox-option:focus-visible { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
.lt-combobox-option.is-selected::before {
content: '✓';
color: var(--accent-green);
@@ -4126,6 +4325,7 @@ html[data-theme="light"] ::-webkit-scrollbar-thumb:hover { background: var(--acc
white-space: nowrap;
}
.lt-context-menu-item:hover { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
.lt-context-menu-item:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: -2px; 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 {
@@ -4337,6 +4537,7 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
}
.lt-split-divider:hover,
.lt-split-divider.is-dragging { background: var(--accent-cyan); }
.lt-split-divider:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 3px; }
.lt-split--vertical .lt-split-divider::before { top: -6px; bottom: -6px; left: 0; right: 0; cursor: row-resize; }
.lt-split--vertical .lt-split-divider { cursor: row-resize; }
/* On mobile, stack vertically and hide divider */
@@ -4481,7 +4682,8 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
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.is-focused,
.lt-typeahead-item:focus-visible { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
.lt-typeahead-item mark {
background: none;
color: var(--accent-orange);
@@ -4751,6 +4953,9 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
.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-close:focus-visible,
.lt-lightbox-prev:focus-visible,
.lt-lightbox-next:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; color: var(--accent-cyan); }
.lt-lightbox-caption {
position: fixed;
bottom: 3rem;
@@ -4798,6 +5003,7 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
border-radius: 2px;
}
.lt-sidebar-group-label:hover { color: var(--text-secondary); }
.lt-sidebar-group-label:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 1px; border-radius: 2px; }
.lt-sidebar-group-label .chevron {
font-size: 0.5rem;
transition: transform 0.2s ease;
@@ -4828,6 +5034,7 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
white-space: nowrap;
}
.lt-sidebar-sub-link:hover { color: var(--accent-cyan); background: var(--accent-cyan-dim); }
.lt-sidebar-sub-link:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 1px; border-radius: 2px; }
.lt-sidebar-sub-link.active,
.lt-sidebar-sub-link[aria-current="page"] {
color: var(--accent-orange);
@@ -4857,6 +5064,7 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
.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 a:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; }
.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; }
@@ -4908,7 +5116,7 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
background: var(--bg-secondary);
border: 1px solid var(--border-color);
clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 0 100%);
z-index: 10020;
z-index: var(--z-panel);
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
transform-origin: top right;
transform: scale(0.95);
@@ -4917,7 +5125,7 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
transition: opacity 0.15s ease, transform 0.15s ease;
overflow: hidden;
}
.lt-notif-panel[aria-hidden="false"] {
.lt-notif-panel:not([aria-hidden]) {
opacity: 1;
transform: scale(1);
pointer-events: auto;
@@ -4947,6 +5155,8 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
letter-spacing: 0.04em;
}
.lt-notif-panel-clear:hover { text-decoration: underline; }
.lt-notif-panel-clear:active { opacity: 0.7; }
.lt-notif-panel-clear:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; border-radius: 2px; }
.lt-notif-panel-list { max-height: min(280px, calc(80vh - 110px)); overflow-y: auto; }
@@ -4960,6 +5170,7 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
transition: background 0.1s;
}
.lt-notif-item:hover { background: var(--bg-tertiary); }
.lt-notif-item:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: -2px; background: var(--bg-tertiary); }
.lt-notif-item--unread { background: rgba(0, 212, 255, 0.04); }
.lt-notif-dot {
@@ -5015,7 +5226,7 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
background: var(--bg-secondary);
border: 1px solid var(--border-color);
clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 0 100%);
z-index: 10020;
z-index: var(--z-panel);
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
transform-origin: top left;
transform: scale(0.95);
@@ -5028,7 +5239,7 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
right: 0;
transform-origin: top right;
}
.lt-dropdown-panel[aria-hidden="false"] {
.lt-dropdown-panel:not([aria-hidden]) {
opacity: 1;
transform: scale(1);
pointer-events: auto;
@@ -5055,6 +5266,12 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
background: var(--bg-tertiary);
color: var(--text-primary);
}
.lt-dropdown-item:focus-visible {
outline: 2px solid var(--accent-cyan);
outline-offset: -2px;
background: var(--bg-tertiary);
color: var(--text-primary);
}
.lt-dropdown-item--danger { color: var(--accent-red); }
.lt-dropdown-item--danger:hover { background: rgba(255,45,85,0.1); color: var(--accent-red); }
@@ -5250,3 +5467,24 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
.lt-stats-grid { grid-template-columns: repeat(8, 1fr); max-width: 100%; }
.lt-modal { max-width: 800px; }
}
/* ----------------------------------------------------------------
79. FOOTER
---------------------------------------------------------------- */
.lt-footer {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--space-sm);
padding: var(--space-md) var(--space-lg);
border-top: 1px solid var(--border-dim);
margin-top: auto;
background: var(--bg-secondary);
color: var(--text-muted);
font-size: 0.7rem;
font-family: var(--font-mono);
}
@media (max-width: 479px) {
.lt-footer { flex-direction: column; align-items: flex-start; gap: 0.25rem; }
}
+228 -196
View File
File diff suppressed because it is too large Load Diff
+239 -64
View File
@@ -73,7 +73,7 @@
function showToast(message, type, duration) {
type = type || 'info';
duration = duration || 3500;
if (_toastActive) { _toastQueue.push({ message, type, duration }); return; }
if (_toastActive) { if (_toastQueue.length < 12) _toastQueue.push({ message, type, duration }); return; }
_displayToast(message, type, duration);
}
@@ -100,6 +100,7 @@
msgEl.textContent = message;
const closeEl = document.createElement('button');
closeEl.type = 'button';
closeEl.className = 'lt-toast-close';
closeEl.textContent = '✕';
closeEl.setAttribute('aria-label', 'Dismiss');
@@ -214,7 +215,7 @@
_modalTriggers.set(el, document.activeElement);
}
el.classList.add('is-open');
el.setAttribute('aria-hidden', 'false');
el.removeAttribute('aria-hidden'); /* removing is correct; setting 'false' is an anti-pattern */
_lockScroll();
// Focus first focusable element
const first = el.querySelector(_FOCUSABLE);
@@ -232,9 +233,14 @@
_unlockScroll();
// Remove trap handler
if (el._ltTrapHandler) { el.removeEventListener('keydown', el._ltTrapHandler); delete el._ltTrapHandler; }
// Return focus to trigger
// Return focus to trigger (only if no other modal remains open)
const trigger = _modalTriggers.get(el);
if (trigger) { trigger.focus(); _modalTriggers.delete(el); }
if (trigger) {
_modalTriggers.delete(el);
if (!document.querySelector('.lt-modal-overlay.is-open') && document.contains(trigger)) {
trigger.focus();
}
}
}
function closeAllModals() {
@@ -263,22 +269,38 @@
lt.tabs.switch('panel-id')
---------------------------------------------------------------- */
function switchTab(panelId) {
document.querySelectorAll('.lt-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.lt-tab[data-tab]').forEach(t => {
t.classList.remove('active');
t.setAttribute('aria-selected', 'false');
});
document.querySelectorAll('.lt-tab-panel').forEach(p => p.classList.remove('active'));
const btn = document.querySelector('.lt-tab[data-tab="' + panelId + '"]');
const panel = document.getElementById(panelId);
if (btn) btn.classList.add('active');
if (btn) { btn.classList.add('active'); btn.setAttribute('aria-selected', 'true'); }
if (panel) panel.classList.add('active');
try { localStorage.setItem('lt_activeTab_' + location.pathname, panelId); } catch (_) {}
}
let _tabsInitialized = false;
function initTabs() {
if (_tabsInitialized) return; _tabsInitialized = true;
try {
const saved = localStorage.getItem('lt_activeTab_' + location.pathname);
if (saved && document.getElementById(saved)) { switchTab(saved); }
} catch (_) {}
document.querySelectorAll('.lt-tab[data-tab]').forEach(btn => {
document.querySelectorAll('[role="tablist"]').forEach(tablist => {
const btns = Array.from(tablist.querySelectorAll('.lt-tab[data-tab]'));
btns.forEach((btn, i) => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
btn.addEventListener('keydown', e => {
let idx = -1;
if (e.key === 'ArrowRight') idx = (i + 1) % btns.length;
else if (e.key === 'ArrowLeft') idx = (i - 1 + btns.length) % btns.length;
else if (e.key === 'Home') idx = 0;
else if (e.key === 'End') idx = btns.length - 1;
if (idx >= 0) { e.preventDefault(); btns[idx].focus(); switchTab(btns[idx].dataset.tab); }
});
});
});
}
@@ -293,6 +315,7 @@
function runBoot(appName, force) {
const storageKey = 'lt_booted_' + (appName || 'app');
if (!force && sessionStorage.getItem(storageKey)) return;
sessionStorage.setItem(storageKey, '1'); // Claim the run immediately to block double-init
const overlay = document.getElementById('lt-boot');
const pre = document.getElementById('lt-boot-text');
if (!overlay || !pre) return;
@@ -338,7 +361,6 @@
overlay.style.opacity = '0';
setTimeout(() => { overlay.style.display = 'none'; overlay.style.opacity = ''; overlay.style.transition = ''; }, 520);
}, 500);
sessionStorage.setItem(storageKey, '1');
}
}, 65);
}
@@ -392,7 +414,9 @@
----------------------------------------------------------------
lt.sidebar.init()
---------------------------------------------------------------- */
let _sidebarInitialized = false;
function initSidebar() {
if (_sidebarInitialized) return; _sidebarInitialized = true;
document.querySelectorAll('[data-sidebar-toggle]').forEach(btn => {
const sidebar = document.getElementById(btn.dataset.sidebarToggle);
if (!sidebar) return;
@@ -429,8 +453,11 @@
lt.api.put / patch / delete
---------------------------------------------------------------- */
async function apiFetch(method, url, body) {
const opts = { method, headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()) };
if (body !== undefined) opts.body = JSON.stringify(body);
const hasBody = body !== undefined;
const headers = Object.assign({}, csrfHeaders());
if (hasBody) headers['Content-Type'] = 'application/json'; // Only set on requests with a body
const opts = { method, headers };
if (hasBody) opts.body = JSON.stringify(body);
let resp;
try { resp = await fetch(url, opts); } catch (err) { throw new Error('Network error: ' + err.message); }
let data;
@@ -536,9 +563,12 @@
const ths = Array.from(table.querySelectorAll('th[data-sort-key]'));
ths.forEach((th, colIdx) => {
let dir = 'asc';
th.addEventListener('click', () => {
ths.forEach(h => h.removeAttribute('data-sort'));
th.setAttribute('aria-sort', 'none');
th.setAttribute('tabindex', '0');
const _sort = () => {
ths.forEach(h => { h.removeAttribute('data-sort'); h.setAttribute('aria-sort', 'none'); });
th.setAttribute('data-sort', dir);
th.setAttribute('aria-sort', dir === 'asc' ? 'ascending' : 'descending');
const tbody = table.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort((a, b) => {
@@ -550,7 +580,9 @@
});
rows.forEach(r => tbody.appendChild(r));
dir = dir === 'asc' ? 'desc' : 'asc';
});
};
th.addEventListener('click', _sort);
th.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _sort(); } });
});
}
@@ -563,14 +595,16 @@
---------------------------------------------------------------- */
function initStatsFilter() {
document.querySelectorAll('.lt-stat-card[data-filter-key]').forEach(card => {
card.addEventListener('click', () => {
const _activate = () => {
const key = card.dataset.filterKey, val = card.dataset.filterVal;
const wasActive = card.classList.contains('active');
document.querySelectorAll('.lt-stat-card').forEach(c => c.classList.remove('active'));
if (!wasActive) card.classList.add('active');
if (typeof global.lt_onStatFilter === 'function')
global.lt_onStatFilter(wasActive ? null : key, wasActive ? null : val);
});
};
card.addEventListener('click', _activate);
card.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _activate(); } });
});
}
@@ -625,7 +659,9 @@
});
}
let _accordionInitialized = false;
function initAccordion() {
if (_accordionInitialized) return; _accordionInitialized = true;
// Support both data-accordion attribute (HTML) and .lt-accordion-trigger class
document.querySelectorAll('[data-accordion], .lt-accordion-trigger').forEach(trigger => {
if (trigger.getAttribute('aria-expanded') === 'true') {
@@ -680,7 +716,11 @@
case 'right': top = r.top + sy + r.height / 2 - tr.height / 2; left = r.right + sx + 8; break;
default: top = r.top + sy - tr.height - 8; left = r.left + sx + r.width / 2 - tr.width / 2;
}
tip.style.cssText = 'position:absolute;top:' + Math.max(4, top) + 'px;left:' + Math.max(4, left) + 'px;z-index:9000';
const maxLeft = (global.scrollX || 0) + global.innerWidth - tr.width - 4;
const maxTop = (global.scrollY || 0) + global.innerHeight - tr.height - 4;
left = Math.max(4 + (global.scrollX || 0), Math.min(maxLeft, left));
top = Math.max(4 + (global.scrollY || 0), Math.min(maxTop, top));
tip.style.cssText = 'position:absolute;top:' + top + 'px;left:' + left + 'px';
requestAnimationFrame(() => tip.classList.add('is-visible'));
}
@@ -688,7 +728,10 @@
if (_tooltipEl) { _tooltipEl.remove(); _tooltipEl = null; }
}
let _tooltipInitialized = false;
function initTooltips() {
if (_tooltipInitialized) return;
_tooltipInitialized = true;
document.addEventListener('mouseover', e => { const a = e.target.closest('[data-tooltip]'); if (a) _tooltipShow(a); });
document.addEventListener('mouseout', e => { if (!e.target.closest('[data-tooltip]')) return; if (!e.relatedTarget || !e.relatedTarget.closest('[data-tooltip]')) _tooltipHide(); });
document.addEventListener('focusin', e => { const a = e.target.closest('[data-tooltip]'); if (a) _tooltipShow(a); });
@@ -718,7 +761,9 @@
} catch (_) { return false; }
}
let _copyInitialized = false;
function initCopyButtons() {
if (_copyInitialized) return; _copyInitialized = true;
document.addEventListener('click', async function (e) {
const btn = e.target.closest('[data-copy]'); if (!btn) return;
const orig = btn.textContent;
@@ -726,7 +771,7 @@
if (ok) {
btn.textContent = 'COPIED ✓'; btn.disabled = true;
if (btn.hasAttribute('data-copy-toast')) toast.success('Copied to clipboard');
setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 1500);
setTimeout(() => { if (document.contains(btn)) { btn.textContent = orig; btn.disabled = false; } }, 1500);
} else { toast.error('Copy failed'); }
});
}
@@ -750,9 +795,11 @@
}));
}
let _alertsInitialized = false;
function initAlerts() {
if (_alertsInitialized) return; _alertsInitialized = true;
document.addEventListener('click', function (e) {
const btn = e.target.closest('.lt-alert-dismiss'); if (!btn) return;
const btn = e.target.closest('.lt-alert-close, .lt-alert-dismiss'); if (!btn) return;
const al = btn.closest('.lt-alert'); if (al) dismissAlert(al);
});
document.querySelectorAll('.lt-alert[data-alert-auto-dismiss]').forEach(el => {
@@ -805,12 +852,13 @@
Command: { id, label, icon?, description?, kbd?, group?, tags?, action }
---------------------------------------------------------------- */
let _cpCommands = [], _cpSelected = 0;
let _cpCommands = [], _cpSelected = 0, _cpTrigger = null;
const _cpRecentKey = 'lt_cmd_recent';
function _cmdPaletteOpen() {
const ov = document.getElementById('lt-cmd-overlay'); if (!ov) return;
if (_mnOpen) _mnSetOpen(false);
_cpTrigger = (document.activeElement && document.activeElement !== document.body) ? document.activeElement : null;
ov.classList.add('is-open');
_lockScroll();
const palette = document.getElementById('lt-cmd-palette');
@@ -823,6 +871,9 @@
const ov = document.getElementById('lt-cmd-overlay'); if (!ov) return;
ov.classList.remove('is-open');
_unlockScroll();
const inp = document.querySelector('#lt-cmd-palette .lt-cmd-input');
if (inp) inp.removeAttribute('aria-activedescendant');
if (_cpTrigger) { if (document.contains(_cpTrigger)) _cpTrigger.focus(); _cpTrigger = null; }
}
function _cpHighlight(text, q) {
@@ -865,7 +916,7 @@
if (!groups[g] || !groups[g].length) return;
html += '<div class="lt-cmd-section-label">' + escHtml(g) + '</div>';
groups[g].forEach(cmd => {
html += '<div class="lt-cmd-item' + (idx === 0 ? ' is-selected' : '') + '" data-cmd-id="' + escHtml(cmd.id) + '">' +
html += '<div class="lt-cmd-item' + (idx === 0 ? ' is-selected' : '') + '" id="lt-cmd-item-' + idx + '" data-cmd-id="' + escHtml(cmd.id) + '">' +
'<span class="lt-cmd-item-icon">' + escHtml(cmd.icon || '◦') + '</span>' +
'<span class="lt-cmd-item-label">' + _cpHighlight(cmd.label, query) + '</span>' +
(cmd.kbd ? '<span class="lt-cmd-item-kbd">' + escHtml(cmd.kbd) + '</span>' : '') +
@@ -875,10 +926,15 @@
});
results.innerHTML = html;
results.querySelectorAll('.lt-cmd-item').forEach((item, i) => {
const pal = document.getElementById('lt-cmd-palette');
const inp = pal && pal.querySelector('.lt-cmd-input');
const allItems = Array.from(results.querySelectorAll('.lt-cmd-item'));
if (inp && allItems[0]) inp.setAttribute('aria-activedescendant', allItems[0].id);
allItems.forEach((item, i) => {
item.addEventListener('mouseenter', () => {
results.querySelectorAll('.lt-cmd-item').forEach(x => x.classList.remove('is-selected'));
allItems.forEach(x => x.classList.remove('is-selected'));
item.classList.add('is-selected'); _cpSelected = i;
if (inp) inp.setAttribute('aria-activedescendant', item.id);
});
item.addEventListener('click', () => _cpExec(item.dataset.cmdId));
});
@@ -903,6 +959,8 @@
_cpSelected = (_cpSelected + dir + items.length) % items.length;
items[_cpSelected] && items[_cpSelected].classList.add('is-selected');
items[_cpSelected] && items[_cpSelected].scrollIntoView({ block: 'nearest' });
const inp = ov.querySelector('.lt-cmd-input');
if (inp && items[_cpSelected]) inp.setAttribute('aria-activedescendant', items[_cpSelected].id);
}
function initCmdPalette(commands) {
@@ -943,6 +1001,8 @@
function _validateField(el) {
const val = el.value || '', type = (el.type || '').toLowerCase();
if ((type === 'checkbox' || type === 'radio') && el.required && !el.checked) return { valid: false, message: 'This field is required' };
if (el.tagName === 'SELECT' && el.multiple && el.required && el.selectedOptions.length === 0) return { valid: false, message: 'Please select at least one option' };
if (el.required && !val.trim()) return { valid: false, message: 'This field is required' };
if (el.minLength > 0 && val.length < el.minLength) return { valid: false, message: 'Minimum ' + el.minLength + ' characters' };
if (el.maxLength > 0 && val.length > el.maxLength) return { valid: false, message: 'Maximum ' + el.maxLength + ' characters' };
@@ -960,12 +1020,22 @@
function _showError(el, msg) {
el.classList.add('is-invalid'); el.classList.remove('is-valid');
let err = el.parentElement && el.parentElement.querySelector('.lt-field-error');
if (!err) { err = document.createElement('span'); err.className = 'lt-field-error'; if (el.parentElement) el.parentElement.appendChild(err); }
if (!err) {
err = document.createElement('span');
err.className = 'lt-field-error';
err.id = (el.id || ('lt-field-' + Math.random().toString(36).slice(2))) + '-err';
if (el.parentElement) el.parentElement.appendChild(err);
}
err.textContent = msg;
err.setAttribute('role', 'alert');
el.setAttribute('aria-describedby', err.id);
el.setAttribute('aria-invalid', 'true');
}
function _clearError(el) {
el.classList.remove('is-invalid'); el.classList.add('is-valid');
el.removeAttribute('aria-invalid');
el.removeAttribute('aria-describedby');
const err = el.parentElement && el.parentElement.querySelector('.lt-field-error');
if (err) err.remove();
}
@@ -990,7 +1060,7 @@
e.preventDefault();
const r = _validateForm(formEl);
if (r.valid && typeof onSubmit === 'function') onSubmit(formEl, e);
else if (!r.valid) r.errors[0].el.focus();
else if (!r.valid && r.errors.length) r.errors[0].el.focus();
});
}
@@ -1343,7 +1413,7 @@
Auto-inits on [id="lt-menu-btn"] + [id="lt-nav-drawer"]
Swipe right from left edge (≤ 20px) opens; swipe left closes.
================================================================ */
let _mnOpen = false;
let _mnOpen = false, _mnTrigger = null;
function _mnSetOpen(open) {
_mnOpen = open;
@@ -1353,20 +1423,28 @@
if (!drawer) return;
if (open) {
_mnTrigger = (document.activeElement && document.activeElement !== document.body) ? document.activeElement : null;
drawer.classList.add('open');
drawer.setAttribute('aria-hidden', 'false');
drawer.removeAttribute('aria-hidden');
if (overlay) overlay.classList.add('open');
if (btn) { btn.classList.add('open'); btn.setAttribute('aria-expanded', 'true'); }
document.body.style.overflow = 'hidden';
// Trap focus inside drawer
if (!drawer._mnTrapHandler) {
drawer._mnTrapHandler = e => { if (e.key === 'Tab') _trapFocus(drawer, e); };
drawer.addEventListener('keydown', drawer._mnTrapHandler);
}
const first = drawer.querySelector('button, a, [tabindex]');
if (first) setTimeout(() => first.focus(), 50);
if (first) setTimeout(() => { if (document.contains(first)) first.focus(); }, 50);
} else {
drawer.classList.remove('open');
drawer.setAttribute('aria-hidden', 'true');
if (overlay) overlay.classList.remove('open');
if (btn) { btn.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); }
document.body.style.overflow = '';
if (drawer._mnTrapHandler) { drawer.removeEventListener('keydown', drawer._mnTrapHandler); delete drawer._mnTrapHandler; }
if (_mnTrigger && document.contains(_mnTrigger)) { _mnTrigger.focus(); }
_mnTrigger = null;
}
bus.emit('mobileNav:' + (open ? 'open' : 'close'));
}
@@ -1544,15 +1622,17 @@
const ov = document.getElementById(ovId);
if (_mnOpen) _mnSetOpen(false);
drawer.classList.add('is-open');
drawer.setAttribute('aria-hidden', 'false');
drawer.removeAttribute('aria-hidden');
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
// ESC to close + Tab trap
drawer._rdKeyHandler = e => { if (e.key === 'Escape') _rdClose(drawer); };
document.addEventListener('keydown', drawer._rdKeyHandler);
drawer._rdTrapHandler = e => { if (e.key === 'Tab') _trapFocus(drawer, e); };
drawer.addEventListener('keydown', drawer._rdTrapHandler);
// Overlay click
if (ov) ov._rdClick = () => _rdClose(drawer);
if (ov) ov.addEventListener('click', ov._rdClick);
@@ -1570,8 +1650,12 @@
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; } }
drawer.querySelectorAll('[data-drawer-close]').forEach(btn => {
if (btn._rdHandler) { btn.removeEventListener('click', btn._rdHandler); delete btn._rdHandler; }
});
_unlockScroll();
if (drawer._rdKeyHandler) { document.removeEventListener('keydown', drawer._rdKeyHandler); delete drawer._rdKeyHandler; }
if (drawer._rdTrapHandler) { drawer.removeEventListener('keydown', drawer._rdTrapHandler); delete drawer._rdTrapHandler; }
const trigger = _modalTriggers.get(drawer);
if (trigger) { trigger.focus(); _modalTriggers.delete(drawer); }
}
@@ -1594,9 +1678,10 @@
lt.contextMenu.register(selector, items)
items = [{ label, icon, kbd, danger, divider, action }]
================================================================ */
let _ctxMenu = null;
let _ctxMenu = null, _ctxTrigger = null;
const _ctxItems = {};
function _ctxShow(x, y, items) {
function _ctxShow(x, y, items, trigger) {
_ctxTrigger = trigger || (document.activeElement !== document.body ? document.activeElement : null);
if (!_ctxMenu) {
_ctxMenu = document.createElement('div');
_ctxMenu.className = 'lt-context-menu';
@@ -1613,14 +1698,22 @@
el.setAttribute('tabindex', '0');
el.innerHTML = `${item.icon ? `<span class="icon">${escHtml(item.icon)}</span>` : '<span class="icon"></span>'}<span>${escHtml(item.label || '')}</span>${item.kbd ? `<kbd>${escHtml(item.kbd)}</kbd>` : ''}`;
el.addEventListener('click', () => { _ctxHide(); if (item.action) item.action(); });
el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); el.click(); } });
el.addEventListener('keydown', e => {
const items = Array.from(_ctxMenu.querySelectorAll('[role="menuitem"]'));
const idx = items.indexOf(e.currentTarget);
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); el.click(); }
else if (e.key === 'ArrowDown') { e.preventDefault(); (items[idx + 1] || items[0]).focus(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); (items[idx - 1] || items[items.length - 1]).focus(); }
else if (e.key === 'Home') { e.preventDefault(); items[0].focus(); }
else if (e.key === 'End') { e.preventDefault(); items[items.length - 1].focus(); }
});
_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.left = Math.max(8, 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"]');
@@ -1628,6 +1721,8 @@
}
function _ctxHide() {
if (_ctxMenu) _ctxMenu.classList.remove('is-open');
if (_ctxTrigger && document.contains(_ctxTrigger)) { _ctxTrigger.focus(); }
_ctxTrigger = null;
}
document.addEventListener('click', () => _ctxHide());
document.addEventListener('contextmenu', e => {
@@ -1636,7 +1731,7 @@
e.preventDefault();
const menuId = target.dataset.contextMenu;
const items = _ctxItems[menuId];
if (items) _ctxShow(e.clientX, e.clientY, items);
if (items) _ctxShow(e.clientX, e.clientY, items, target);
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') _ctxHide(); });
const contextMenu = {
@@ -1791,6 +1886,15 @@
let focusedIdx = -1;
let filtered = [...options];
// ARIA combobox wiring
const dropId = dropdown.id || ('lt-cb-drop-' + Math.random().toString(36).slice(2));
dropdown.id = dropId;
dropdown.setAttribute('role', 'listbox');
inputEl.setAttribute('role', 'combobox');
inputEl.setAttribute('aria-expanded', 'false');
inputEl.setAttribute('aria-controls', dropId);
inputEl.setAttribute('aria-autocomplete', 'list');
function _renderTags() {
wrap.querySelectorAll('.lt-combobox-tag').forEach(t => t.remove());
selected.forEach(v => {
@@ -1798,7 +1902,7 @@
if (!opt) return;
const tag = document.createElement('span');
tag.className = 'lt-combobox-tag';
tag.innerHTML = `${escHtml(opt.label)}<button class="lt-combobox-tag-remove" data-value="${escHtml(v)}" aria-label="Remove ${escHtml(opt.label)}">✕</button>`;
tag.innerHTML = `${escHtml(opt.label)}<button type="button" class="lt-combobox-tag-remove" data-value="${escHtml(v)}" aria-label="Remove ${escHtml(opt.label)}">✕</button>`;
inputWrap.insertBefore(tag, inputEl);
});
}
@@ -1813,10 +1917,12 @@
}
filtered.forEach((opt, i) => {
const el = document.createElement('div');
el.id = dropId + '-opt-' + i;
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'), '<mark>$1</mark>') : escHtml(opt.label);
const safeLabel = escHtml(opt.label);
const hl = q ? safeLabel.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '<mark>$1</mark>') : safeLabel;
el.innerHTML = `${opt.icon ? `<span class="icon">${escHtml(opt.icon)}</span>` : ''}<span>${hl}</span>`;
el.addEventListener('mousedown', e => { e.preventDefault(); _toggle(opt.value); });
dropdown.appendChild(el);
@@ -1836,20 +1942,26 @@
}
function _moveFocus(dir) {
const items = dropdown.querySelectorAll('.lt-combobox-option');
const items = Array.from(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.setAttribute('aria-activedescendant', items[focusedIdx].id);
}
inputEl.addEventListener('input', () => { dropdown.classList.add('is-open'); _renderDropdown(inputEl.value); });
inputEl.addEventListener('focus', () => { dropdown.classList.add('is-open'); _renderDropdown(inputEl.value); });
function _setOpen(open) {
dropdown.classList.toggle('is-open', open);
inputEl.setAttribute('aria-expanded', open ? 'true' : 'false');
if (!open) { inputEl.removeAttribute('aria-activedescendant'); focusedIdx = -1; }
}
inputEl.addEventListener('input', () => { _setOpen(true); _renderDropdown(inputEl.value); });
inputEl.addEventListener('focus', () => { _setOpen(true); _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 === 'Escape') { _setOpen(false); }
if (e.key === 'Backspace' && !inputEl.value && selected.length) { _toggle(selected[selected.length - 1]); }
});
inputWrap.addEventListener('mousedown', e => {
@@ -1857,7 +1969,7 @@
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'); });
document.addEventListener('click', e => { if (!wrap.contains(e.target)) _setOpen(false); });
_renderTags();
_renderDropdown('');
@@ -1897,9 +2009,11 @@
const q = query.toLowerCase();
_items.forEach((item, i) => {
const el = document.createElement('div');
el.id = (inputEl.id || 'lt-ta') + '-item-' + i;
el.className = 'lt-typeahead-item';
el.setAttribute('role', 'option');
const hl = item.label.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '<mark>$1</mark>');
const safeItemLabel = escHtml(item.label);
const hl = safeItemLabel.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '<mark>$1</mark>');
el.innerHTML = `${item.icon ? `<span class="icon">${escHtml(item.icon)}</span>` : ''}<span>${hl}</span>${item.meta ? `<span style="margin-left:auto;color:var(--text-muted);font-size:0.68rem">${escHtml(item.meta)}</span>` : ''}`;
el.addEventListener('mousedown', e => { e.preventDefault(); _select(item); });
dropdown.appendChild(el);
@@ -1910,27 +2024,32 @@
async function _search(query) {
dropdown.innerHTML = '<div class="lt-typeahead-loading">Searching…</div>';
dropdown.classList.add('is-open');
inputEl.setAttribute('aria-busy', 'true');
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 = '<div class="lt-typeahead-empty">Error loading results</div>';
} finally {
inputEl.setAttribute('aria-busy', 'false');
}
}
function _select(item) {
inputEl.value = item.label;
inputEl.removeAttribute('aria-activedescendant');
dropdown.classList.remove('is-open');
if (onSelect) onSelect(item);
bus.emit('typeahead:select', { item });
}
function _moveFocus(dir) {
const els = dropdown.querySelectorAll('.lt-typeahead-item');
const els = Array.from(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.setAttribute('aria-activedescendant', els[_focusedIdx].id);
}
inputEl.addEventListener('input', () => {
@@ -2035,6 +2154,25 @@
});
divider.addEventListener('pointerup', () => { dragging = false; divider.classList.remove('is-dragging'); });
// Keyboard resize support
divider.setAttribute('tabindex', '0');
divider.setAttribute('role', 'separator');
divider.setAttribute('aria-label', 'Resize panes');
divider.addEventListener('keydown', e => {
const step = 0.05;
const total = vertical ? container.clientHeight : container.clientWidth;
const divSize = vertical ? divider.offsetHeight : divider.offsetWidth;
const available = total - divSize;
const currentSize = vertical ? panes[0].offsetHeight : panes[0].offsetWidth;
const currentRatio = currentSize / available;
if ((e.key === 'ArrowRight' && !vertical) || (e.key === 'ArrowDown' && vertical)) {
e.preventDefault(); _setRatio(Math.min(1, currentRatio + step));
} else if ((e.key === 'ArrowLeft' && !vertical) || (e.key === 'ArrowUp' && vertical)) {
e.preventDefault(); _setRatio(Math.max(0, currentRatio - step));
} else if (e.key === 'Home') { e.preventDefault(); _setRatio(0); }
else if (e.key === 'End') { e.preventDefault(); _setRatio(1); }
});
_setRatio(initial);
return { setRatio: _setRatio };
},
@@ -2052,10 +2190,21 @@
group._sbInit = true;
const label = group.querySelector('.lt-sidebar-group-label');
if (!label) return;
label.addEventListener('click', () => group.classList.toggle('is-open'));
label.setAttribute('tabindex', '0');
label.setAttribute('role', 'button');
const chevron = label.querySelector('.chevron, .lt-sidebar-chevron');
if (chevron) chevron.setAttribute('aria-hidden', 'true');
const _toggle = () => {
group.classList.toggle('is-open');
label.setAttribute('aria-expanded', group.classList.contains('is-open') ? 'true' : 'false');
};
label.setAttribute('aria-expanded', group.classList.contains('is-open') ? 'true' : 'false');
label.addEventListener('click', _toggle);
label.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _toggle(); } });
// 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');
label.setAttribute('aria-expanded', 'true');
}
});
}
@@ -2119,8 +2268,9 @@
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); } };
const _onScrollThrottled = throttle(_onScroll, 150);
scrollRoot.addEventListener('scroll', _onScrollThrottled, { passive: true });
return { reset() { _done = false; _loading = false; }, stop() { scrollRoot.removeEventListener('scroll', _onScrollThrottled); } };
}
},
};
@@ -2151,7 +2301,7 @@
function _show(idx) {
steps.forEach((s, i) => {
s.classList.toggle('is-active', i === idx);
s.setAttribute('aria-hidden', i !== idx ? 'true' : 'false');
if (i !== idx) s.setAttribute('aria-hidden', 'true'); else s.removeAttribute('aria-hidden');
});
// Update step indicators
container.querySelectorAll('[data-wizard-indicator]').forEach((ind, i) => {
@@ -2175,7 +2325,11 @@
if (first) setTimeout(() => first.focus(), 60);
}
let _wizBusy = false;
async function _next() {
if (_wizBusy) return;
_wizBusy = true;
try {
if (validate) {
const ok = await validate(current + 1, _getStepData(current));
if (!ok) {
@@ -2185,6 +2339,9 @@
}
Object.assign(formData, _getStepData(current));
if (current < total - 1) { current++; _show(current); }
} finally {
_wizBusy = false;
}
}
function _prev() {
@@ -2367,7 +2524,7 @@
const lightbox = {
init(selector, opts = {}) {
const { caption = 'alt', loop = true } = opts;
let _images = [], _current = 0, _overlay = null;
let _images = [], _current = 0, _overlay = null, _lbKeyBound = null, _lbTrigger = null;
function _getCaption(img) {
if (typeof caption === 'function') return caption(img);
@@ -2382,9 +2539,9 @@
_overlay.setAttribute('aria-modal', 'true');
_overlay.setAttribute('aria-label', 'Image viewer');
_overlay.innerHTML = `
<button class="lt-lightbox-close" aria-label="Close">&times;</button>
<button class="lt-lightbox-prev" aria-label="Previous">&#8249;</button>
<button class="lt-lightbox-next" aria-label="Next">&#8250;</button>
<button type="button" class="lt-lightbox-close" aria-label="Close">&times;</button>
<button type="button" class="lt-lightbox-prev" aria-label="Previous">&#8249;</button>
<button type="button" class="lt-lightbox-next" aria-label="Next">&#8250;</button>
<div class="lt-lightbox-img-wrap">
<img class="lt-lightbox-img" src="" alt="">
</div>
@@ -2397,7 +2554,8 @@
_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);
_lbKeyBound = _lbKey.bind(null);
document.addEventListener('keydown', _lbKeyBound);
}
function _lbKey(e) {
@@ -2409,6 +2567,9 @@
function _show(idx) {
if (!_overlay) _buildOverlay();
if (!_overlay.classList.contains('is-open')) {
_lbTrigger = (document.activeElement && document.activeElement !== document.body) ? document.activeElement : null;
}
_current = idx;
const img = _images[idx];
const el = _overlay.querySelector('.lt-lightbox-img');
@@ -2420,7 +2581,7 @@
_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);
setTimeout(() => { if (document.contains(el)) el.focus?.(); }, 50);
}
function _collect() {
@@ -2444,6 +2605,9 @@
if (!_overlay) return;
_overlay.classList.remove('is-open');
_unlockScroll();
if (_lbKeyBound) { document.removeEventListener('keydown', _lbKeyBound); _lbKeyBound = null; }
if (_lbTrigger && document.contains(_lbTrigger)) { _lbTrigger.focus(); }
_lbTrigger = null;
},
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)); },
@@ -2574,10 +2738,16 @@
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/__(.+?)__/g, '<strong>$1</strong>')
.replace(/_(.+?)_/g, '<em>$1</em>')
// Links
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
// Images
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%">')
// Links — block javascript: and data: URIs
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) => {
const safeUrl = /^(https?:\/\/|\/|#|\.\.?\/)/i.test(url) ? url : '#';
return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${escHtml(text)}</a>`;
})
// Images — block javascript: and data: URIs
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) => {
const safeSrc = /^(https?:\/\/|\/|\.\.?\/)/i.test(src) ? src : '';
return `<img src="${safeSrc}" alt="${escHtml(alt)}" style="max-width:100%">`;
})
// Blockquote
.replace(/^&gt;\s(.+)$/gm, '<blockquote>$1</blockquote>')
// Horizontal rule
@@ -2622,7 +2792,7 @@
const pages = _pages();
let html = '';
// Prev
html += `<button class="lt-page-btn" ${page <= 1 ? 'disabled' : ''} data-page="${page - 1}">&laquo;</button>`;
html += `<button type="button" class="lt-page-btn" ${page <= 1 ? 'disabled' : ''} data-page="${page - 1}" aria-label="Previous page">&laquo;</button>`;
// Page buttons with ellipsis
const half = Math.floor((maxBtns - 2) / 2);
let start = Math.max(2, page - half);
@@ -2631,15 +2801,17 @@
if (start === 2) end = Math.min(pages - 1, start + maxBtns - 3);
else start = Math.max(2, end - maxBtns + 3);
}
html += `<button class="lt-page-btn${page === 1 ? ' active' : ''}" data-page="1">1</button>`;
if (start > 2) html += `<button class="lt-page-btn" disabled>…</button>`;
html += `<button type="button" class="lt-page-btn${page === 1 ? ' active' : ''}" data-page="1"${page === 1 ? ' aria-current="page"' : ''} aria-label="Page 1">1</button>`;
if (start > 2) html += `<button type="button" class="lt-page-btn" disabled aria-hidden="true">…</button>`;
for (let i = start; i <= end; i++) {
html += `<button class="lt-page-btn${page === i ? ' active' : ''}" data-page="${i}">${i}</button>`;
html += `<button type="button" class="lt-page-btn${page === i ? ' active' : ''}" data-page="${i}"${page === i ? ' aria-current="page"' : ''} aria-label="Page ${i}">${i}</button>`;
}
if (end < pages - 1) html += `<button class="lt-page-btn" disabled>…</button>`;
if (pages > 1) html += `<button class="lt-page-btn${page === pages ? ' active' : ''}" data-page="${pages}">${pages}</button>`;
if (end < pages - 1) html += `<button type="button" class="lt-page-btn" disabled aria-hidden="true">…</button>`;
if (pages > 1) html += `<button type="button" class="lt-page-btn${page === pages ? ' active' : ''}" data-page="${pages}"${page === pages ? ' aria-current="page"' : ''} aria-label="Page ${pages}">${pages}</button>`;
// Next
html += `<button class="lt-page-btn" ${page >= pages ? 'disabled' : ''} data-page="${page + 1}">&raquo;</button>`;
html += `<button type="button" class="lt-page-btn" ${page >= pages ? 'disabled' : ''} data-page="${page + 1}" aria-label="Next page">&raquo;</button>`;
if (!nav.getAttribute('role')) nav.setAttribute('role', 'navigation');
if (!nav.getAttribute('aria-label')) nav.setAttribute('aria-label', 'Pagination');
nav.innerHTML = html;
}
@@ -2671,7 +2843,10 @@
alerts: bool, clipboard: bool, sidebar: bool, submenus: bool }
Individual modules can still be called manually.
================================================================ */
let _ltInitialized = false;
function ltInit(opts) {
if (_ltInitialized) return; // Guard: safe to call multiple times
_ltInitialized = true;
const o = Object.assign({
boot: true,
bootName: null,