Compare commits

..

28 Commits

Author SHA1 Message Date
jared 0af46954d7 feat: back-port design system utilities from tinker_tickets to web_template
Lint / JS (eslint) (push) Successful in 10s
Sticky footer layout:
- body: add display:flex + flex-direction:column
- .lt-main: add flex:1 + width:100% + min-width:0 so it fills remaining height
- .lt-main.lt-container combined-selector specificity fix (prevents lt-container
  padding from overriding lt-main padding-top in responsive breakpoints)
- Responsive breakpoints updated to use .lt-main.lt-container combined selector

Modal utilities:
- .lt-modal-xs (280px), .lt-modal-sm (360px) — size modifiers
- .lt-modal-header--danger — danger variant (red bg tint + accent border/title)

Badge:
- .lt-badge-sm — compact size (0.5rem font, 0.05/0.3rem padding)

KV rows:
- .lt-kv-row (display:contents), .lt-kv-label, .lt-kv-value — alternate KV
  row pattern where children become direct lt-kv-grid items

Avatar image overlay:
- .lt-avatar: add position:relative so img can overlay initials
- .lt-avatar img: use absolute inset:0 overlay (was flat width/height)
- .lt-avatar img.lt-avatar-img-err: display:none when JS marks broken image

Extended markdown styles:
- list-style: disc/decimal + colored ::marker (cyan/orange)
- mark, del, sub, sup, task-item, task-cb, task-done, task-todo
- Footnote helpers: fn-ref, fn-hr, fn-list, fn-item, fn-back
- .md-image alias on img rule

Footer keyboard hints:
- .lt-footer-hints, .lt-footer-hint, .lt-footer-key, .lt-footer-sep

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 17:37:41 -04:00
jared 8df14ebbe3 fix: polish SLA banner component — gradient fill, dismiss persistence, terminal icons
Lint / JS (eslint) (push) Successful in 6s
- lt-sla-p2 .lt-sla-fill: upgrade from flat amber to gradient (#FFB300 →
  #ffd740) for visual consistency with P1 red→orange fill
- lt-sla-dismiss: add transition (0.15s ease) and :focus-visible outline so
  keyboard users get a visible focus ring
- Demo dismiss: replace .remove() with hidden + sessionStorage so banners
  stay dismissed across page navigation (data-sla-id attribute wires the key)
- Demo icons: swap emoji (🔴 🟠) for terminal-style text [ ! ] / [ ~ ] —
  emoji rendering is platform-specific and breaks the monospace aesthetic

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 17:27:10 -04:00
jared 39862fab3b feat: add SLA banner component and gradient progress bar fills from design test
Lint / JS (eslint) (push) Successful in 8s
SLA banners (.lt-sla-p1 / .lt-sla-p2):
- P1 pulsing red banner with lt-sla-pulse keyframe
- P2 static amber banner
- Subcomponents: lt-sla-icon, lt-sla-info, lt-sla-title, lt-sla-bar,
  lt-sla-fill, lt-sla-meta, lt-sla-dismiss
- Light theme overrides included
- Demo section added to base.html with dismiss wiring

Progress bar gradient fills:
- Default (orange), --cyan, --green, --red variants now use
  linear-gradient fills instead of flat accent colors for more
  dramatic terminal readout appearance

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 17:18:33 -04:00
jared 8f2f310fe2 Add unprefixed class aliases for monitoring app compatibility
Lint / JS (eslint) (push) Successful in 7s
Add .dot-up/.dot-down/.dot-degraded/.dot-unknown as aliases for lt-dot-* variants; add .chip/.chip-ok/.chip-warning/.chip-critical as aliases for lt-chip-* variants; add .lt-table tr.row-* state definitions for table row colouring.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 23:46:57 -04:00
jared 19d6a2883c docs: add new-app guide and EJS layout skeleton
Lint / JS (eslint) (push) Successful in 13s
README.md:
- Add 'Starting a New App' section with step-by-step instructions:
  nginx alias setup, which skeleton to copy, how to define nav for
  each framework, app.css pattern, lt.init() call
- Update File Structure section: remove app-specific labels from
  framework skeletons (was 'PHP / Tinker Tickets' etc.), add
  layout.ejs to the node/ listing

node/layout.ejs:
- New EJS base layout skeleton matching the PHP/Python equivalents:
  generic nav via navLinks locals, lt-* class names throughout,
  CSP nonce on all script tags, pageStyles/pageScripts arrays,
  CURRENT_USER + CSRF_TOKEN globals injected at runtime

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 14:00:01 -04:00
jared 75c57092f8 fix: make layout templates generic — remove app-specific nav and config
Lint / JS (eslint) (push) Successful in 10s
php/layout.php: nav is now data-driven via $navLinks array (supports
  top-level links, dropdowns, adminOnly flag); removed tinker_tickets
  hardcoded nav items; moved APP_CONFIG note to comment
python/base.html: nav driven by nav_links list from context; removed
  gandalf-specific routes (links_page, inspector, suppressions_page);
  removed APP_CONFIG.ticketWebUrl from shared script block; added
  nav_links format documentation in header comment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 13:53:29 -04:00
jared f61705afb8 docs: add Authelia portal integration guide
Lint / JS (eslint) (push) Successful in 12s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 16:30:39 -04:00
jared 140a57a029 docs: add CI lint badge to README
Lint / JS (eslint) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 16:28:23 -04:00
jared f9445ed5ae ci: add ESLint workflow
Lint / JS (eslint) (push) Has been cancelled
- .gitea/workflows/lint.yml: lint base.js and node/ directory
- .eslintrc.json: browser + node environment, CommonJS
- .gitignore: ignore node_modules/ and .env
- package.json + package-lock.json: eslint@8 dev dependency

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 16:25:53 -04:00
jared 044adb3a18 Fix nav dropdown dismissing when cursor moves into menu
top:calc(100%+4px) left a 4px dead zone between the trigger and
menu that broke :hover continuity. Changed to top:100% with
padding-top:6px + margin-top:-2px so the hoverable area is
contiguous. Updated ::before decorative line to top:6px to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 00:43:33 -04:00
jared 842190d225 fix: light theme override for lt-nav-dropdown-menu
Menu had hardcoded dark background (rgba(6,12,20,0.98)) with no light
theme rule, leaving it black regardless of theme toggle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:59:57 -04:00
jared a807944b50 feat: implement RECOMMENDED_ADDITIONS — cursor, scanlines, radar, display-field, VT323
- --font-crt: 'VT323' token added to :root; VT323 added to Google Fonts link
- .lt-cursor / .lt-cursor--cyan/orange/red — blinking block cursor via CSS ::after
- .lt-scanlines — opt-in CRT horizontal scanline overlay on body/container (light-mode suppressed)
- .lt-radar / --sm / --lg / --green — radar sweep loading indicator as lt-spinner alternative
- .lt-display-field — readable non-editable field variant (distinct from :disabled opacity:0.45)
- base.html demos: radar variants in loading row, display-field in forms, cursor+VT323 in tags section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 16:50:46 -04:00
jared 1b7e57d9f5 Add gotchas section: disabled/readonly display patterns
Documents the opacity:0.45 behavior on :disabled and [readonly] elements
and the correct workarounds for display-only contexts (edit-mode toggle
selects, copy inputs, pre-wrap description areas).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 19:43:49 -04:00
jared 083a918729 docs: add LDAP avatar integration guide to README
Documents the lldap service account setup, avatar endpoint pattern,
CSS photo-over-initials approach, and HTML template used in tinker_tickets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 21:04:57 -04:00
jared de30ff13e6 fix: add in-memory _bootFired flag to runBoot as primary dedup guard
sessionStorage alone could be bypassed in hash-URL edge cases. A module-scoped
_bootFired flag blocks any second runBoot call within the same JS context
regardless of sessionStorage state, then sessionStorage handles cross-reload
suppression. Also restores boot call in private init() to preserve original
dual-path structure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 14:52:31 -04:00
jared 32f7b49f98 fix: remove boot call from private init() to prevent hash-URL double-boot
Private init() and lt.init() both called runBoot(), creating two code paths.
With base.html# URLs the sessionStorage guard could be bypassed causing every
boot line to appear twice. Boot is now exclusively triggered by lt.init() or
lt.boot.run(), which already has its own _ltInitialized guard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 14:46:34 -04:00
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
13 changed files with 4117 additions and 849 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"env": { "browser": true, "node": true, "es2021": true },
"parserOptions": { "ecmaVersion": 2021, "sourceType": "script" },
"rules": {
"no-unused-vars": "warn",
"no-undef": "warn",
"no-empty": "warn",
"semi": ["error", "always"],
"eqeqeq": "warn"
}
}
+20
View File
@@ -0,0 +1,20 @@
name: Lint
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
js-lint:
name: JS (eslint)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install ESLint
run: npm install --save-dev eslint@8
- name: Run ESLint
run: npx eslint --ext .js base.js node/
+3
View File
@@ -0,0 +1,3 @@
node_modules/
.env
*.env
+495
View File
@@ -0,0 +1,495 @@
# Authelia Portal — LotusGuild Terminal Design System Integration
This document covers everything that had to be **written from scratch** to theme the
Authelia authentication portal using the LotusGuild Terminal Design System v1.2.
It is a companion to `base.css` / `base.js` for future integrations with third-party
applications that use their own component frameworks.
---
## Why a separate file at all?
`base.css` targets exclusively `.lt-*` class names — the design system's own prefix.
Authelia's frontend is a React single-page application built on
**Material UI (MUI) v5**, which generates its own stable class names like
`.MuiCard-root`, `.MuiButton-containedPrimary`, etc.
Bridging the two requires a translation layer: the same design tokens and visual
patterns from `base.css`, re-expressed against MUI's class structure.
Additionally, Authelia v4.x (latest: 4.39.x) does **not** support native CSS
injection — `server.asset_path` only handles `logo.png` and `favicon.ico`.
The CSS is injected via **nginx `sub_filter`** in Nginx Proxy Manager, which rewrites
Authelia's HTML on the fly to include a `<link>` tag before `</head>`.
---
## Deployment architecture
```
Browser
└─► https://auth.lotusguild.org (NPM — LXC 139)
├─ GET /custom.css ──────── served from /data/custom_assets/authelia-custom.css
│ (nginx alias, no upstream request)
└─ GET /* ──────────────── proxied to Authelia (LXC 167 :9091)
HTML response is rewritten by sub_filter:
</head> → <link rel="stylesheet" href="/custom.css"></head>
```
**NPM config changed:** `/data/nginx/proxy_host/29.conf` (auth.lotusguild.org proxy host)
```nginx
# Serve LotusGuild Terminal custom CSS
location = /custom.css {
alias /data/custom_assets/authelia-custom.css;
add_header Content-Type "text/css";
add_header Cache-Control "public, max-age=3600";
}
location / {
...existing headers...
# Prevent upstream gzip so sub_filter can inspect the body
proxy_set_header Accept-Encoding "";
# Inject CSS link before </head>
sub_filter '</head>' '<link rel="stylesheet" href="/custom.css"></head>';
sub_filter_once on;
include /etc/nginx/conf.d/include/proxy.conf;
}
```
> **Warning:** NPM regenerates proxy_host conf files when you edit a proxy host in
> the UI. If host 29 is ever modified through NPM, re-apply the `sub_filter` block
> and the `/custom.css` location manually, or set the config via NPM's
> "Advanced" tab before saving.
**Authelia config changed:** `/etc/authelia/configuration.yml` — added `asset_path`
under `server:` so Authelia serves the resized logo and favicon:
```yaml
server:
address: tcp://0.0.0.0:9091
asset_path: /etc/authelia/assets
```
Assets in `/etc/authelia/assets/`:
- `logo.png` — 256 × 256 PNG resized from Tinker Tickets `assets/images/favicon.png`
- `favicon.ico` — 32 × 32 ICO from the same source
---
## What is NOT in `base.css` and had to be written
The sections below are ordered as they appear in `authelia-custom.css`.
---
### 1. Re-declaring design tokens with `!important`
**In `base.css`:** `:root { ... }` is declared once, without `!important`.
**What had to be added:** The entire `:root` block is repeated in `custom.css` because
Authelia's HTML is a self-contained SPA — `base.css` is never loaded. More critically,
MUI injects its own inline styles and CSS-in-JS rules at very high specificity.
Every property that needs to override MUI requires `!important`. `base.css` never
uses `!important` because it owns its namespace; here we are guests in MUI's DOM.
```css
/* base.css does NOT use !important anywhere */
body { background-color: var(--bg-primary); }
/* custom.css must fight MUI's inline styles */
body { background-color: var(--bg-primary) !important; }
```
The token values themselves are identical to `base.css` — only the override
mechanism is new.
---
### 2. Universal box-sizing reset with `!important`
**In `base.css`:** `*, *::before, *::after { box-sizing: border-box; }` — no flag needed
because `base.css` loads first.
**What had to be added:**
```css
*, *::before, *::after {
box-sizing: border-box !important;
}
```
MUI components set `box-sizing` inline on some elements. Without the flag the
clip-path geometry breaks on inputs and buttons.
---
### 3. MUI typography selectors
**In `base.css`:** Typography rules target `h1h6`, `a`, `p`, `body` — standard HTML
elements. No MUI class names exist in the design system.
**What had to be added:** Authelia renders text almost exclusively through MUI's
`<Typography>` component, which produces elements with `.MuiTypography-root` and
variant classes. Without targeting these, all text inherits MUI's Roboto font and
light grey colour instead of JetBrains Mono and `--text-primary`.
```css
.MuiTypography-root,
.MuiInputBase-input,
.MuiFormLabel-root,
.MuiFormHelperText-root,
label, p, span, div {
font-family: var(--font-mono) !important;
color: var(--text-primary) !important;
}
h1, h2, h3, h4, h5, h6,
.MuiTypography-h5,
.MuiTypography-h6 {
color: var(--accent-orange) !important;
text-shadow: var(--glow-orange) !important;
...
}
```
---
### 4. `.MuiCard-root` / `.MuiPaper-root` — the login card
**In `base.css`:** `.lt-card` implements the terminal card with `clip-path`, border,
background, and the `::before` corner triangle accent.
**What had to be added:** Authelia wraps its login form in a MUI `<Card>` (which
extends `<Paper>`). Neither selector exists in `base.css`. The visual result is
identical to `.lt-card` — the clip-path polygon, cyan border, box-glow, and
corner triangle are all copied — but they target different class names and require
`!important` throughout to override MUI's elevation shadows and `border-radius: 4px`.
```css
.MuiCard-root,
.MuiPaper-root {
background: var(--bg-card) !important;
border: 1px solid var(--border-color) !important;
border-radius: 0 !important; /* ← overrides MUI default */
clip-path: polygon(...) !important; /* same geometry as .lt-card */
...
}
.MuiCard-root::before,
.MuiPaper-root::before {
/* same corner triangle as .lt-card::before */
}
```
---
### 5. Logo element — `img[alt="Authelia"]`
**In `base.css`:** No equivalent. The design system has no logo slot component.
**What had to be added:** Authelia renders the portal logo as
`<img alt="Authelia" src="./static/media/logo.png">`. This selector targets it
specifically to apply the cyan drop-shadow and orange hover glow. Without it the
logo renders without any terminal aesthetic treatment.
```css
img[alt="Authelia"] {
filter: drop-shadow(0 0 12px rgba(0,212,255,0.4)) !important;
transition: filter 0.25s ease !important;
}
img[alt="Authelia"]:hover {
filter: drop-shadow(0 0 18px rgba(255,107,0,0.5)) !important;
}
```
---
### 6. MUI outlined input family
**In `base.css`:** `.lt-input`, `.lt-select`, `.lt-textarea` — custom elements with
`clip-path`, terminal background, and cyan focus ring.
**What had to be added:** MUI's outlined text field is a composition of three
separate elements that each need individual targeting:
| MUI class | What it controls | Equivalent in `base.css` |
|-----------|-----------------|--------------------------|
| `.MuiOutlinedInput-root` | Outer wrapper — background, clip-path | `.lt-input` outer |
| `.MuiOutlinedInput-notchedOutline` | The SVG border element | `.lt-input` border |
| `.MuiInputBase-input` | The actual `<input>` inside | `.lt-input` inner text |
| `.MuiInputLabel-root` | Floating label | `.lt-label` |
| `.MuiFormHelperText-root` | Error / hint text below field | `.lt-form-hint` |
MUI splits the border rendering into a separate SVG `<fieldset>` element
(`.MuiOutlinedInput-notchedOutline`), which requires its own border-color override.
`base.css` has no equivalent splitting since `.lt-input` is a single element with a
CSS border.
The `.Mui-focused` and `.Mui-error` state classes also need explicit overrides because
MUI applies its own colour through CSS-in-JS with high specificity.
```css
.MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline {
border-color: var(--accent-cyan) !important;
box-shadow: var(--box-glow-cyan) !important;
}
.MuiFormHelperText-root.Mui-error {
color: var(--accent-red) !important;
text-shadow: var(--glow-red) !important;
}
```
Additionally, `caret-color` is set explicitly on the input because MUI does not
expose this through its theme — the blinking text cursor would otherwise be white.
---
### 7. MUI button family
**In `base.css`:** `.lt-btn`, `.lt-btn-primary`, `.lt-btn-danger`, `.lt-btn-ghost`
all using `clip-path` hexagon cuts, transparent backgrounds, and border+glow styling.
**What had to be added:** MUI uses four separate button variant classes. The primary
(sign-in) button is `.MuiButton-containedPrimary` which by default renders as a
solid filled rectangle. The entire button appearance — transparent background,
border, clip-path, text-transform, letter-spacing — has to be explicitly overridden
because MUI's `contained` variant applies an opaque `background-color` inline.
```css
/* MUI default: background-color: #1976d2; border-radius: 4px; box-shadow: ... */
.MuiButton-containedPrimary {
background: transparent !important; /* fight the inline fill */
border: 1px solid var(--accent-orange-border) !important;
border-radius: 0 !important;
clip-path: polygon(...) !important;
...
}
```
Text/link buttons (`.MuiButton-text`) are separate from contained buttons in MUI but
map to the same `.lt-btn-ghost` pattern in `base.css`.
---
### 8. MUI checkbox and switch
**In `base.css`:** `.lt-checkbox`, `.lt-switch` — fully custom-drawn using CSS only.
**What had to be added:** Authelia uses MUI's `<Checkbox>` (remember-me) and
`<Switch>` (settings toggles). These are SVG-based components. Because MUI controls
their colour through injected CSS variables, the only reliable override is to target
`.Mui-checked` state classes and use `!important`. There is no equivalent split
between `track` and `thumb` in `base.css`.
```css
.MuiSwitch-switchBase.Mui-checked { color: var(--accent-cyan) !important; }
.MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track {
background-color: var(--accent-cyan) !important;
opacity: 0.5 !important;
}
```
---
### 9. MUI alert / notification banners
**In `base.css`:** `.lt-inline-*` for inline messages (`.lt-inline-success`,
`.lt-inline-error`, etc.) and `.lt-toast-*` for toasts. These use a left border
accent pattern.
**What had to be added:** Authelia uses MUI `<Alert>` for login errors, success
messages, and 2FA prompts. MUI Alert has its own severity system with four variant
classes (`.MuiAlert-standardError`, etc.). Each severity needs explicit colour,
background, and border-color overrides. The `border-left: 3px solid currentColor`
pattern is adapted from `base.css`'s inline message style but applied to MUI's
class names with `clip-path: none !important` to cancel MUI's rounded corners.
---
### 10. MUI select, menu, and popover
**In `base.css`:** `.lt-select` for the select element itself; no dropdown overlay
component exists since the design system uses native `<select>`.
**What had to be added:** MUI's `<Select>` renders its dropdown as a `<Paper>`
element portalled to `<body>` with class `.MuiMenu-paper`. Without targeting this
separately, the dropdown popup inherits the browser default white background and
Roboto font, appearing completely unstyled. `.MuiMenuItem-root` hover and selected
states also need individual rules.
---
### 11. OTP / 2FA digit inputs — `input[type="tel"]`, `input[type="number"]`
**In `base.css`:** No equivalent. The design system has no numeric code input.
**What had to be added:** Authelia's one-time-password entry uses `<input type="tel">`
elements arranged in a row. These need oversized, centered, cyan-glowing characters
with wide letter-spacing to render like a retro code display.
```css
input[type="tel"],
input[type="number"] {
font-size: 1.2rem !important;
font-weight: 700 !important;
color: var(--accent-cyan) !important;
text-shadow: var(--glow-cyan) !important;
letter-spacing: 0.15em !important;
text-align: center !important;
caret-color: var(--accent-orange) !important;
}
```
---
### 12. MUI stepper — 2FA flow breadcrumb
**In `base.css`:** `.lt-stepper`, `.lt-step`, `.lt-step-num` — a custom CSS stepper
for multi-step wizard flows.
**What had to be added:** Authelia renders a MUI `<Stepper>` at the top of the 2FA
flow showing steps like "Username → Password → TOTP". MUI Stepper uses distinct
classes for label, icon, and connector that don't map 1-to-1 to `.lt-step-*`.
The step icon is an SVG circle rendered by React so it can only be tinted via
`color` and `filter: drop-shadow(...)`. The connector line between steps is a
separate `<hr>` with class `.MuiStepConnector-line`.
```css
.MuiStepIcon-root.Mui-active {
color: var(--accent-orange) !important;
filter: drop-shadow(0 0 6px rgba(255,107,0,0.6)) !important;
}
.MuiStepIcon-root.Mui-completed {
color: var(--accent-green) !important;
}
```
---
### 13. MUI linear and circular progress
**In `base.css`:** `.lt-progress` / `.lt-progress-bar` — a standard CSS progress bar
using `width` transitions. `.lt-spinner` — a rotating pseudo-element spinner.
**What had to be added:** Authelia uses MUI `<LinearProgress>` during API calls and
`<CircularProgress>` as the main loading spinner. MUI Linear Progress animates its
own internal elements (`.MuiLinearProgress-bar`) with keyframe animations; it cannot
be replaced with the `.lt-progress` pattern. The cyan-to-orange gradient and
`box-shadow` glow are new here — `base.css`'s progress bar uses a solid
`--accent-orange` fill.
```css
.MuiLinearProgress-bar {
background: linear-gradient(90deg, var(--accent-cyan), var(--accent-orange)) !important;
box-shadow: 0 0 8px rgba(0,212,255,0.5) !important;
}
```
---
### 14. MUI tooltip
**In `base.css`:** `.lt-tooltip` — a CSS-only tooltip shown via `:hover` on a
`[data-tooltip]` attribute.
**What had to be added:** MUI `<Tooltip>` portals its bubble to `<body>` with class
`.MuiTooltip-tooltip`. It is a separate DOM node, not a pseudo-element, so
`.lt-tooltip` styles cannot reach it. A standalone rule sets the terminal background,
monospace font, and squared border.
---
### 15. `*:focus-visible` global ring
**In `base.css`:** Focus rings are defined individually per component
(`.lt-btn:focus-visible`, `.lt-input:focus-visible`, etc.).
**What had to be added:** A single global rule is more practical here because
Authelia contains many interactive MUI elements not known at design time, and
adding per-component rules for every possible focusable element would be brittle.
```css
*:focus-visible {
outline: 2px solid var(--accent-cyan) !important;
outline-offset: 2px !important;
box-shadow: var(--box-glow-cyan) !important;
}
```
---
### 16. Branding footer watermark — `#root::after`
**In `base.css`:** No equivalent. The design system does not add page-level
watermarks.
**What had to be added:** Authelia has no footer. The `#root::after` pseudo-element
attaches a fixed-position text watermark reading
`LOTUSGUILD SECURE PORTAL // AUTH.LOTUSGUILD.ORG` at the bottom of the viewport.
This uses `--text-muted`, `--font-mono`, uppercase, and wide letter-spacing to match
the terminal aesthetic without adding any HTML.
```css
#root::after {
content: 'LOTUSGUILD SECURE PORTAL // AUTH.LOTUSGUILD.ORG';
position: fixed;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
font-size: 0.55rem;
letter-spacing: 0.18em;
color: var(--text-muted);
opacity: 0.6;
pointer-events: none;
}
```
---
## Patterns shared with `base.css` (not new)
The following are **not** documented above because they are direct re-uses of
existing `base.css` patterns, only re-targeted to new selectors:
| Pattern | `base.css` source | Re-used in `custom.css` for |
|---------|------------------|-----------------------------|
| Dot-grid background | `html { background-image: radial-gradient(...) }` | `body` |
| Scanlines overlay | `body::before` | `body::before` (same rule, `!important` added) |
| Corner vignette | `body::after` | `body::after` (same rule, `!important` added) |
| Card clip-path polygon | `.lt-card { clip-path: polygon(...) }` | `.MuiCard-root` |
| Card corner triangle | `.lt-card::before` | `.MuiCard-root::before` |
| Button clip-path hexagon | `.lt-btn { clip-path: polygon(...) }` | `.MuiButton-containedPrimary` |
| Input clip-path cut | `.lt-input { clip-path: polygon(...) }` | `.MuiOutlinedInput-root` |
| Scrollbar | `::-webkit-scrollbar-*` section 45 | identical, `border-radius: 0` added |
| Link colours | `a { color: var(--accent-cyan) }` | `a` (same, `!important` added) |
| Divider | `.lt-divider` border-color | `.MuiDivider-root` |
---
## Notes for future third-party integrations
1. **Always check for `!important` requirements.** CSS-in-JS frameworks (MUI, Emotion,
styled-components) inject styles at runtime with high specificity. Without
`!important`, most design token overrides will lose to injected inline styles.
2. **The split border problem.** MUI (and many component libraries) separate the
border from the element it visually belongs to. Always inspect the actual DOM
before writing selectors — what looks like one element is often three.
3. **Portalled overlays.** Dropdowns, tooltips, and modals in MUI are rendered into
a separate DOM tree (`<body>` portal). Scoped selectors inside a card or form
won't reach them. Target their root classes (`.MuiMenu-paper`, `.MuiTooltip-tooltip`)
directly.
4. **State classes.** MUI uses `.Mui-focused`, `.Mui-checked`, `.Mui-error`,
`.Mui-active`, `.Mui-completed`, `.Mui-disabled` as state modifiers. These are the
equivalent of `:focus`, `:checked`, etc. but applied by JavaScript — pseudo-class
selectors alone will sometimes not fire.
5. **SVG-based components.** Checkboxes, radio buttons, step icons, and spinners are
SVGs injected by React. They cannot be styled with `background`, `border`, or
`clip-path`. Use `color` (SVG `currentColor` inheritance) and `filter:
drop-shadow()` instead.
+1091 -478
View File
File diff suppressed because it is too large Load Diff
+599 -56
View File
File diff suppressed because it is too large Load Diff
+283 -198
View File
File diff suppressed because it is too large Load Diff
+251 -73
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 => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab)); const btns = Array.from(tablist.querySelectorAll('.lt-tab[data-tab]'));
btns.forEach((btn, i) => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
btn.addEventListener('keydown', e => {
let idx = -1;
if (e.key === 'ArrowRight') idx = (i + 1) % btns.length;
else if (e.key === 'ArrowLeft') idx = (i - 1 + btns.length) % btns.length;
else if (e.key === 'Home') idx = 0;
else if (e.key === 'End') idx = btns.length - 1;
if (idx >= 0) { e.preventDefault(); btns[idx].focus(); switchTab(btns[idx].dataset.tab); }
});
});
}); });
} }
@@ -290,9 +312,13 @@
lt.boot.run('APP NAME') lt.boot.run('APP NAME')
lt.boot.run('APP NAME', true) // force replay lt.boot.run('APP NAME', true) // force replay
---------------------------------------------------------------- */ ---------------------------------------------------------------- */
let _bootFired = false; // in-memory guard: survives within a JS context, resets on true page reload
function runBoot(appName, force) { function runBoot(appName, force) {
if (!force && _bootFired) return; // Fastest guard — blocks any same-page double-call
const storageKey = 'lt_booted_' + (appName || 'app'); const storageKey = 'lt_booted_' + (appName || 'app');
if (!force && sessionStorage.getItem(storageKey)) return; if (!force && sessionStorage.getItem(storageKey)) return;
_bootFired = true;
sessionStorage.setItem(storageKey, '1');
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 +364,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 +417,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 +456,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 +566,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 +583,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 +598,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 +662,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 +719,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 +731,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 +764,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 +774,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 +798,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 +855,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 +874,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 +919,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 +929,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 +962,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 +1004,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 +1023,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 +1063,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 +1416,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 +1426,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 +1625,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 +1653,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 +1681,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 +1701,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 +1724,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 +1734,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 +1889,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 +1905,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 +1920,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 +1945,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 +1972,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 +2012,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 +2027,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 +2157,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 +2193,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 +2271,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 +2304,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,16 +2328,23 @@
if (first) setTimeout(() => first.focus(), 60); if (first) setTimeout(() => first.focus(), 60);
} }
let _wizBusy = false;
async function _next() { async function _next() {
if (validate) { if (_wizBusy) return;
const ok = await validate(current + 1, _getStepData(current)); _wizBusy = true;
if (!ok) { try {
container.querySelectorAll('[data-wizard-indicator]')[current]?.classList.add('is-error'); if (validate) {
return; const ok = await validate(current + 1, _getStepData(current));
if (!ok) {
container.querySelectorAll('[data-wizard-indicator]')[current]?.classList.add('is-error');
return;
}
} }
Object.assign(formData, _getStepData(current));
if (current < total - 1) { current++; _show(current); }
} finally {
_wizBusy = false;
} }
Object.assign(formData, _getStepData(current));
if (current < total - 1) { current++; _show(current); }
} }
function _prev() { function _prev() {
@@ -2367,7 +2527,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 +2542,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 +2557,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 +2570,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 +2584,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 +2608,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 +2741,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 +2795,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 +2804,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 +2846,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,
+146
View File
@@ -0,0 +1,146 @@
<%
LOTUSGUILD TERMINAL DESIGN SYSTEM — Node.js / Express EJS Base Layout
Extend this in every page template via res.render('page', { ... }).
Required Express setup (server.js / app.js):
const { requireAuth, cspNonce, injectLocals } = require('./middleware');
app.use(cspNonce);
app.use(requireAuth);
app.use(injectLocals);
app.set('view engine', 'ejs');
Locals injected automatically by middleware.js:
user { username, name, email, groups, isAdmin }
nonce CSP nonce string
appName process.env.APP_NAME
appSubtitle process.env.APP_SUBTITLE
Locals to set per-route (or via a second res.locals middleware):
pageTitle string — page <title> suffix
activeNav string — must match a navLinks[].key
navLinks array — navigation items:
[{ href: '/path', key: 'mykey', label: 'My Page' }, ...]
Dropdown:
{ label: 'Admin', key: 'admin', adminOnly: true, children: [
{ href: '/admin/users', label: 'Users' }
]}
pageStyles array — optional extra CSS hrefs
pageScripts array — optional extra <script src> paths
%>
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#030508">
<meta name="robots" content="noindex, nofollow">
<title><%= pageTitle ? pageTitle + ' — ' : '' %><%= appName || 'LotusGuild' %></title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&family=VT323&display=swap" rel="stylesheet">
<!-- Design system -->
<link rel="stylesheet" href="/web_template/base.css">
<!-- App-specific CSS (extends base, never overrides variables without good reason) -->
<link rel="stylesheet" href="/assets/app.css">
<% if (typeof pageStyles !== 'undefined') { pageStyles.forEach(href => { %>
<link rel="stylesheet" href="<%- href %>">
<% }); } %>
<link rel="icon" href="/assets/favicon.png" type="image/png">
</head>
<body>
<!-- Boot overlay -->
<div id="lt-boot" class="lt-boot-overlay"
data-app-name="<%= (appName || 'APP').toUpperCase() %>"
style="display:none">
<pre id="lt-boot-text" class="lt-boot-text"></pre>
</div>
<!-- =========================================================
HEADER
========================================================= -->
<header class="lt-header">
<div class="lt-header-left">
<div class="lt-brand">
<a href="/" class="lt-brand-title" style="text-decoration:none">
<%= (appName || 'APP').toUpperCase() %>
</a>
<span class="lt-brand-subtitle"><%= appSubtitle || 'LotusGuild Infrastructure' %></span>
</div>
<nav class="lt-nav" aria-label="Main navigation">
<% (navLinks || []).forEach(link => {
if (link.adminOnly && !user.isAdmin) return;
if (link.children) { %>
<div class="lt-nav-dropdown">
<a href="#" class="lt-nav-link <%= (activeNav || '').startsWith(link.key) ? 'active' : '' %>">
<%= link.label %> ▾
</a>
<ul class="lt-nav-dropdown-menu">
<% link.children.forEach(child => { %>
<li><a href="<%= child.href %>"><%= child.label %></a></li>
<% }); %>
</ul>
</div>
<% } else { %>
<a href="<%= link.href %>"
class="lt-nav-link <%= activeNav === link.key ? 'active' : '' %>">
<%= link.label %>
</a>
<% } }); %>
</nav>
</div>
<div class="lt-header-right">
<% if (user && (user.name || user.username)) { %>
<span class="lt-header-user"><%= user.name || user.username %></span>
<% } %>
<% if (user && user.isAdmin) { %>
<span class="lt-badge-admin">admin</span>
<% } %>
</div>
</header>
<!-- =========================================================
MAIN CONTENT — provided by the including view via <%- body %>
or by structuring routes to render with this as a wrapper.
========================================================= -->
<main class="lt-main lt-container">
<%- body %>
</main>
<!-- =========================================================
SCRIPTS — all tags carry the CSP nonce
========================================================= -->
<!-- Runtime globals -->
<script nonce="<%= nonce %>">
window.CSRF_TOKEN = <%= JSON.stringify(csrfToken || '') %>;
window.CURRENT_USER = {
username: <%= JSON.stringify(user.username || '') %>,
name: <%= JSON.stringify(user.name || '') %>,
groups: <%= JSON.stringify(user.groups || []) %>,
isAdmin: <%= user.isAdmin ? 'true' : 'false' %>,
};
// App-specific config: set window.APP_CONFIG in your route's inline script,
// not here. This file is shared across all apps.
</script>
<!-- Design system -->
<script nonce="<%= nonce %>" src="/web_template/base.js"></script>
<!-- App JS -->
<script nonce="<%= nonce %>" src="/assets/app.js"></script>
<% if (typeof pageScripts !== 'undefined') { pageScripts.forEach(src => { %>
<script nonce="<%= nonce %>" src="<%- src %>"></script>
<% }); } %>
</body>
</html>
+1140
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
{
"devDependencies": {
"eslint": "^8.57.1"
}
}
+36 -21
View File
@@ -17,8 +17,14 @@
* $nonce string CSP nonce from SecurityHeadersMiddleware::getNonce() * $nonce string CSP nonce from SecurityHeadersMiddleware::getNonce()
* $currentUser array ['username', 'name', 'is_admin', 'groups'] * $currentUser array ['username', 'name', 'is_admin', 'groups']
* $pageTitle string Page <title> suffix * $pageTitle string Page <title> suffix
* $activeNav string Which nav link is active ('dashboard','tickets',etc.) * $activeNav string Which nav key is active — must match a $navLinks entry
* $config array From config/config.php * $config array From config/config.php
* $navLinks array Navigation items:
* [['href' => '/path', 'key' => 'mykey', 'label' => 'My Page'], ...]
* Nested (dropdown):
* ['label' => 'Admin', 'key' => 'admin', 'adminOnly' => true, 'children' => [
* ['href' => '/admin/users', 'label' => 'Users'],
* ]]
*/ */
// Defensive defaults // Defensive defaults
@@ -27,6 +33,7 @@ $currentUser = $currentUser ?? [];
$pageTitle = $pageTitle ?? 'Dashboard'; $pageTitle = $pageTitle ?? 'Dashboard';
$activeNav = $activeNav ?? ''; $activeNav = $activeNav ?? '';
$config = $config ?? []; $config = $config ?? [];
$navLinks = $navLinks ?? [];
$isAdmin = $currentUser['is_admin'] ?? false; $isAdmin = $currentUser['is_admin'] ?? false;
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
@@ -40,7 +47,7 @@ $isAdmin = $currentUser['is_admin'] ?? false;
<!-- Unified design system CSS --> <!-- Unified design system CSS -->
<link rel="stylesheet" href="/web_template/base.css"> <link rel="stylesheet" href="/web_template/base.css">
<!-- App-specific CSS (extends base, never overrides variables without good reason) --> <!-- App-specific CSS (extends base, never overrides variables without good reason) -->
<link rel="stylesheet" href="/assets/css/app.css?v=<?php echo $config['CSS_VERSION'] ?? '20260314'; ?>"> <link rel="stylesheet" href="/assets/css/app.css?v=<?php echo $config['CSS_VERSION'] ?? '1'; ?>">
<link rel="icon" href="/assets/images/favicon.png" type="image/png"> <link rel="icon" href="/assets/images/favicon.png" type="image/png">
</head> </head>
@@ -67,25 +74,31 @@ $isAdmin = $currentUser['is_admin'] ?? false;
</div> </div>
<nav class="lt-nav" aria-label="Main navigation"> <nav class="lt-nav" aria-label="Main navigation">
<a href="/" class="lt-nav-link <?php echo $activeNav === 'dashboard' ? 'active' : ''; ?>">Dashboard</a> <?php foreach ($navLinks as $link): ?>
<a href="/tickets" class="lt-nav-link <?php echo $activeNav === 'tickets' ? 'active' : ''; ?>">Tickets</a> <?php
$skipAdminOnly = !empty($link['adminOnly']) && !$isAdmin;
if ($skipAdminOnly) continue;
?>
<?php if ($isAdmin): ?> <?php if (!empty($link['children'])): ?>
<div class="lt-nav-dropdown"> <?php $parentActive = str_starts_with($activeNav, $link['key']); ?>
<a href="#" class="lt-nav-link <?php echo str_starts_with($activeNav, 'admin') ? 'active' : ''; ?>"> <div class="lt-nav-dropdown">
Admin ▾ <a href="#" class="lt-nav-link <?php echo $parentActive ? 'active' : ''; ?>">
</a> <?php echo htmlspecialchars($link['label']); ?> ▾
<ul class="lt-nav-dropdown-menu"> </a>
<li><a href="/admin/templates">Templates</a></li> <ul class="lt-nav-dropdown-menu">
<li><a href="/admin/workflow">Workflow</a></li> <?php foreach ($link['children'] as $child): ?>
<li><a href="/admin/recurring-tickets">Recurring</a></li> <li><a href="<?php echo htmlspecialchars($child['href']); ?>"><?php echo htmlspecialchars($child['label']); ?></a></li>
<li><a href="/admin/custom-fields">Custom Fields</a></li> <?php endforeach; ?>
<li><a href="/admin/user-activity">User Activity</a></li> </ul>
<li><a href="/admin/audit-log">Audit Log</a></li> </div>
<li><a href="/admin/api-keys">API Keys</a></li> <?php else: ?>
</ul> <a href="<?php echo htmlspecialchars($link['href']); ?>"
</div> class="lt-nav-link <?php echo $activeNav === $link['key'] ? 'active' : ''; ?>">
<?php endif; ?> <?php echo htmlspecialchars($link['label']); ?>
</a>
<?php endif; ?>
<?php endforeach; ?>
</nav> </nav>
</div> </div>
@@ -130,6 +143,8 @@ $isAdmin = $currentUser['is_admin'] ?? false;
username: <?php echo json_encode($currentUser['username'] ?? ''); ?>, username: <?php echo json_encode($currentUser['username'] ?? ''); ?>,
isAdmin: <?php echo $isAdmin ? 'true' : 'false'; ?>, isAdmin: <?php echo $isAdmin ? 'true' : 'false'; ?>,
}; };
// App-specific config: set window.APP_CONFIG in your app's own <script> block,
// not here. This file is shared across all apps.
</script> </script>
<!-- Unified design system JS --> <!-- Unified design system JS -->
@@ -137,7 +152,7 @@ $isAdmin = $currentUser['is_admin'] ?? false;
<!-- App-specific JS (cache-busted) --> <!-- App-specific JS (cache-busted) -->
<script nonce="<?php echo $nonce; ?>" <script nonce="<?php echo $nonce; ?>"
src="/assets/js/app.js?v=<?php echo $config['JS_VERSION'] ?? '20260314'; ?>"> src="/assets/js/app.js?v=<?php echo $config['JS_VERSION'] ?? '1'; ?>">
</script> </script>
<!-- Per-page inline JS goes here in the including view, e.g.: --> <!-- Per-page inline JS goes here in the including view, e.g.: -->
+37 -23
View File
@@ -17,7 +17,8 @@
Required Flask setup (app.py): Required Flask setup (app.py):
- Pass `nonce` into every render_template() call via a context processor - Pass `nonce` into every render_template() call via a context processor
- Pass `user` dict from _get_user() helper - Pass `user` dict from _get_user() helper
- Pass `config` dict with APP_NAME, etc. - Pass `config` dict with APP_NAME, APP_SUBTITLE, etc.
- Pass `nav_links` list of dicts defining navigation
Context processor example: Context processor example:
@app.context_processor @app.context_processor
@@ -25,6 +26,16 @@
import base64, os import base64, os
nonce = base64.b64encode(os.urandom(16)).decode() nonce = base64.b64encode(os.urandom(16)).decode()
return dict(nonce=nonce, user=_get_user(), config=_config()) return dict(nonce=nonce, user=_get_user(), config=_config())
nav_links format (pass from route or context processor):
nav_links = [
{'href': url_for('index'), 'key': 'dashboard', 'label': 'Dashboard'},
{'href': url_for('settings'), 'key': 'settings', 'label': 'Settings'},
# Admin-only dropdown:
{'label': 'Admin', 'key': 'admin', 'admin_only': True, 'children': [
{'href': url_for('admin_users'), 'label': 'Users'},
]},
]
#} #}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -64,24 +75,28 @@
</div> </div>
<nav class="lt-nav" aria-label="Main navigation"> <nav class="lt-nav" aria-label="Main navigation">
{# Each page sets {% block active_nav %}pagename{% endblock %} #}
{% set active = self.active_nav() | default('') %} {% set active = self.active_nav() | default('') %}
<a href="{{ url_for('index') }}" {% for link in nav_links | default([]) %}
class="lt-nav-link {% if active == 'dashboard' %}active{% endif %}"> {% if not link.get('admin_only') or 'admin' in user.groups %}
Dashboard {% if link.get('children') %}
</a> <div class="lt-nav-dropdown">
<a href="{{ url_for('links_page') }}" <a href="#" class="lt-nav-link {% if active.startswith(link.key) %}active{% endif %}">
class="lt-nav-link {% if active == 'links' %}active{% endif %}"> {{ link.label }} ▾
Link Debug </a>
</a> <ul class="lt-nav-dropdown-menu">
<a href="{{ url_for('inspector') }}" {% for child in link.children %}
class="lt-nav-link {% if active == 'inspector' %}active{% endif %}"> <li><a href="{{ child.href }}">{{ child.label }}</a></li>
Inspector {% endfor %}
</a> </ul>
<a href="{{ url_for('suppressions_page') }}" </div>
class="lt-nav-link {% if active == 'suppressions' %}active{% endif %}"> {% else %}
Suppressions <a href="{{ link.href }}"
</a> class="lt-nav-link {% if active == link.key %}active{% endif %}">
{{ link.label }}
</a>
{% endif %}
{% endif %}
{% endfor %}
</nav> </nav>
</div> </div>
@@ -107,17 +122,16 @@
All <script> tags MUST carry the nonce attribute for CSP. All <script> tags MUST carry the nonce attribute for CSP.
========================================================= --> ========================================================= -->
<!-- Runtime config (no CSRF needed for Gandalf — SameSite=Strict) --> <!-- Runtime config injected by the server -->
<script nonce="{{ nonce }}"> <script nonce="{{ nonce }}">
window.APP_CONFIG = {
ticketWebUrl: {{ config.get('ticket_api', {}).get('web_url', 'https://t.lotusguild.org/ticket/') | tojson }},
};
window.CURRENT_USER = { window.CURRENT_USER = {
username: {{ user.username | tojson }}, username: {{ user.username | tojson }},
name: {{ (user.name or user.username) | tojson }}, name: {{ (user.name or user.username) | tojson }},
groups: {{ user.groups | tojson }}, groups: {{ user.groups | tojson }},
isAdmin: {{ ('admin' in user.groups) | lower }}, isAdmin: {{ ('admin' in user.groups) | lower }},
}; };
// App-specific config: set window.APP_CONFIG in your app's own template block,
// not here. This file is shared across all apps.
</script> </script>
<!-- Unified design system JS --> <!-- Unified design system JS -->
@@ -136,6 +150,6 @@
{% block active_nav %}dashboard{% endblock %} {% block active_nav %}dashboard{% endblock %}
Values: dashboard | links | inspector | suppressions Value must match a 'key' in your nav_links list.
--------------------------------------------------------------- #} --------------------------------------------------------------- #}
{% block active_nav %}{% endblock %} {% block active_nav %}{% endblock %}