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