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

LotusGuild Terminal Design System — v1.2

Aesthetic: Anduril × Hacker Terminal — dark military-tech, multi-accent neon, angular clip-path frames, glitch effects.

A single-file design system (base.css + base.js) used across all LotusGuild internal services. base.html is a living reference template that demonstrates every component and pattern.


Table of Contents

  1. Overview
  2. Quick Start
  3. Design Tokens
  4. Breakpoints
  5. Typography
  6. Color Palette
  7. Component Catalog
  8. JavaScript API
  9. Theming (Dark / Light)
  10. Accessibility
  11. File Structure
  12. LDAP Avatar Integration
  13. Changelog

Overview

File Purpose
base.css All styles — ~5,200 lines, 79 sections
base.js All JS — vanilla IIFE, window.lt namespace, 55+ modules
base.html Full component reference / demo page

No build step. No npm. No framework. Drop in the two files and go.


Quick Start

<!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">
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
  <link rel="stylesheet" href="/base.css">
</head>
<body>
  <!-- your app markup here -->
  <script src="/base.js"></script>
  <script>
    document.addEventListener('DOMContentLoaded', () => {
      lt.init({ bootName: 'MY APP' });
    });
  </script>
</body>
</html>

lt.init() — master initializer

lt.init(opts?) runs all standard auto-init modules in one call. Safe to call multiple times — a guard prevents double-initialization.

lt.init({
  bootName: 'MY APP',   // shown in CRT boot overlay (optional)
  skipBoot:  false,     // set true to skip the boot sequence
  csrf:      'token',   // CSRF token string (optional; overrides meta-tag detection)
});

Modules initialized automatically: tabs, accordion, tooltip, clipboard.initCopyButtons, alerts, sidebar, keys.initDefaults, mobileNav.


Design Tokens

All tokens are CSS custom properties on :root. Override any token in your app stylesheet after importing base.css.

Backgrounds

Token Dark Light
--bg-primary #030508 #edf0f5
--bg-secondary #060c14 #e2e8f0
--bg-tertiary #0d1520 #cdd5e0
--bg-card #07101a #f5f7fb
--bg-terminal #010304 #d8dfe8
--bg-overlay rgba(3,5,8,0.94) rgba(237,240,245,0.96)

Text

Token Dark Light
--text-primary #e8edf5 #1a2233
--text-secondary #8899b0 #3a4a60
--text-muted #4a5a70 #6a7a90
--text-accent #FF6B00 #c44e00

Borders

Token Dark Light
--border-color rgba(255,107,0,0.25) rgba(130,140,160,0.5)
--border-dim rgba(255,107,0,0.10) rgba(130,140,160,0.25)
--border-bright rgba(255,107,0,0.6) rgba(196,78,0,0.5)

Spacing

--space-xs (0.25rem) · --space-sm (0.5rem) · --space-md (1rem) · --space-lg (1.5rem) · --space-xl (2rem) · --space-2xl (3rem)

Layout

Token Value
--sidebar-width 240px
--header-height 56px
--container-max 1600px

Transitions

Token Value
--transition-fast all 0.12s ease
--transition-default all 0.25s ease

Corner Cuts

Token Value Usage
--corner-cut 8px Standard clip-path notch
--corner-cut-sm 5px Small elements
--corner-cut-lg 16px Large modals / hero cards

Z-Index Ladder

Token Value Layer
--z-base 1 Stacking context base
--z-dropdown 100 Dropdowns, autocomplete
--z-sticky 200 Sticky elements
--z-fixed 300 Fixed header/sidebar
--z-overlay 9999 CRT scanlines / boot overlay
--z-modal-backdrop 10010 Modal scrim
--z-modal 10011 Modal dialogs
--z-popover 10012 Popovers
--z-tooltip 10013 Tooltips
--z-toast 10014 Toast notifications
--z-panel 10020 Notification / dropdown panels

Breakpoints

Mobile-first. 8 tiers:

Name Range CSS min-width
xs ≤ 479px — (base)
sm 480767px 480px
md 7681023px 768px
lg 10241279px 1024px
xl 12801535px 1280px
2xl 15361919px 1536px
3xl 19202559px 1920px
4k ≥ 2560px 2560px

Check breakpoints in JavaScript:

lt.viewport.bp()       // → 'xs' | 'sm' | 'md' | ... | '4k'
lt.viewport.is('md')   // → true if current bp >= md
lt.viewport.on(cb)     // subscribe to resize/bp changes
lt.viewport.touch()    // true if primary pointer is coarse
lt.viewport.landscape()

Typography

Font: JetBrains Mono (Google Fonts) — weights 400, 600, 700; italic 400.

Load via:

<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
Token Value
--font-mono 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace
--font-size-base 0.875rem (14px)
--line-height 1.6

Heading classes: .lt-h1 through .lt-h6. Utility: .lt-text-xs, .lt-text-sm, .lt-text-lg, .lt-text-xl, .lt-text-muted, .lt-text-accent, .lt-font-mono, .lt-font-bold.


Color Palette

Dark Mode (default)

Name Hex Variable
Orange (primary) #FF6B00 --accent-orange
Cyan #00D4FF --accent-cyan
Green #00FF88 --accent-green
Red #FF2D55 --accent-red
Purple #BF5FFF --accent-purple
Amber #FFB300 --accent-amber

Light Mode

All accents are desaturated for readability on light backgrounds:

Name Dark Light
Orange #FF6B00 #c44e00
Cyan #00D4FF #0062b8
Green #00FF88 #006d35
Red #FF2D55 #b8001f
Purple #BF5FFF #7c22cc

Glows in dark mode use box-shadow with color spread. Light mode replaces neon glows with drop-shadow rings (e.g., 0 0 0 1px rgba(196,78,0,0.25), 0 1px 6px rgba(196,78,0,0.18)).


Component Catalog

All components use the .lt- prefix.

Layout

<!-- App shell -->
<div class="lt-app-shell">
  <header class="lt-header"></header>
  <aside class="lt-sidebar"></aside>
  <main class="lt-main lt-container"></main>
  <footer class="lt-footer"></footer>
</div>

<!-- Grid system -->
<div class="lt-grid lt-grid-2"></div>    <!-- 2-col -->
<div class="lt-grid lt-grid-3"></div>    <!-- 3-col -->
<div class="lt-grid lt-grid-4"></div>    <!-- 4-col -->
<div class="lt-grid lt-grid-auto"></div> <!-- auto-fit, min 280px -->
<div class="lt-stats-grid"></div>        <!-- auto-fit, min 200px -->

ASCII Frame System

The signature angled clip-path card border:

<div class="lt-frame"></div>
<div class="lt-frame lt-frame--cyan"></div>
<div class="lt-frame lt-frame--green"></div>

<!-- With corner label -->
<div class="lt-frame">
  <div class="lt-frame-label">SECTION HEADER</div>
  …content…
</div>

Cards

<div class="lt-card">
  <div class="lt-card-header"></div>
  <div class="lt-card-body"></div>
  <div class="lt-card-footer"></div>
</div>
<div class="lt-card lt-card--orange"></div>
<div class="lt-card lt-card--cyan"></div>

Buttons

<button type="button" class="lt-btn lt-btn-primary">Action</button>
<button type="button" class="lt-btn lt-btn-secondary">Cancel</button>
<button type="button" class="lt-btn lt-btn-danger">Delete</button>
<button type="button" class="lt-btn lt-btn-ghost">Ghost</button>
<button type="button" class="lt-btn lt-btn-primary lt-btn-sm">Small</button>
<button type="button" class="lt-btn lt-btn-primary lt-btn-lg">Large</button>
<button type="button" class="lt-btn lt-btn-primary" disabled>Disabled</button>

<!-- Icon button -->
<button type="button" class="lt-icon-btn" aria-label="Settings"></button>

<!-- With spinner -->
<button type="button" class="lt-btn lt-btn-primary lt-btn-loading">
  <span class="lt-spinner lt-spinner--sm"></span> Loading…
</button>

Forms & Inputs

<div class="lt-form-group">
  <label class="lt-label" for="field">Field Label</label>
  <input type="text" class="lt-input" id="field" autocomplete="off">
  <span class="lt-field-hint">Helper text</span>
</div>

<select class="lt-select"></select>
<textarea class="lt-textarea"></textarea>

<!-- Search -->
<div class="lt-search">
  <input type="search" class="lt-input lt-search-input" autocomplete="off">
</div>

<!-- Toggle switch -->
<label class="lt-toggle">
  <input type="checkbox" class="lt-toggle-input">
  <span class="lt-toggle-slider"></span>
</label>

<!-- Range slider -->
<input type="range" class="lt-range">

<!-- File dropzone -->
<div class="lt-dropzone" id="my-dropzone">
  <span>Drop files here or click to browse</span>
</div>

JS: lt.dropzone.init(el, { onFiles(files){} })

Form validation:

lt.validate.init(formEl, ({ data, errors }) => {
  // called on submit when valid
});
// Custom validator
lt.validate.custom['my-rule'] = (val) => val.length > 3 ? null : 'Too short';

Status Badges

<span class="lt-badge lt-badge-open">Open</span>
<span class="lt-badge lt-badge-closed">Closed</span>
<span class="lt-badge lt-badge-progress">In Progress</span>
<span class="lt-badge lt-badge-critical">Critical</span>
<span class="lt-badge lt-badge-high">High</span>
<span class="lt-badge lt-badge-medium">Medium</span>
<span class="lt-badge lt-badge-low">Low</span>

<!-- Notification dot -->
<span class="lt-notif-badge" data-count="3"></span>

JS: lt.notif.set(el, n) / lt.notif.inc(el) / lt.notif.clear(el)

Tables

<div class="lt-table-wrap">
  <table class="lt-table" id="my-table">
    <caption class="lt-sr-only">Accessible caption</caption>
    <thead>
      <tr>
        <th><input type="checkbox" class="lt-checkbox lt-select-all"></th>
        <th data-sort="name">Name ↕</th>
      </tr>
    </thead>
    <tbody></tbody>
  </table>
</div>

JS:

lt.tableNav.init('my-table');     // keyboard nav (arrow keys, enter)
lt.sortTable.init('my-table');    // click-to-sort columns
lt.tableColumns.init('my-table'); // show/hide columns

Responsive: collapses to card layout below 767px.

Modals

<button type="button" class="lt-btn lt-btn-primary" data-modal-open="my-modal">Open</button>

<div class="lt-modal-backdrop" id="my-modal" role="dialog" aria-modal="true"
     aria-labelledby="my-modal-title" aria-hidden="true">
  <div class="lt-modal">
    <div class="lt-modal-header">
      <h2 class="lt-modal-title" id="my-modal-title">Dialog Title</h2>
      <button type="button" class="lt-modal-close" data-modal-close aria-label="Close"></button>
    </div>
    <div class="lt-modal-body"></div>
    <div class="lt-modal-footer">
      <button type="button" class="lt-btn lt-btn-secondary" data-modal-close>Cancel</button>
      <button type="button" class="lt-btn lt-btn-primary">Confirm</button>
    </div>
  </div>
</div>

JS: lt.modal.open('my-modal') / lt.modal.close('my-modal') / lt.modal.closeAll()

Toasts

lt.toast.success('Saved!');
lt.toast.error('Something went wrong', 8000);
lt.toast.warning('Disk space low');
lt.toast.info('Deployment queued', 5000);

Toast queue is capped at 12 pending notifications. All types support an optional duration in ms (default: 4000).

Drawers (Side Panels)

<div class="lt-drawer" id="my-drawer" role="dialog" aria-hidden="true">
  <div class="lt-drawer-header">
    <h3>Drawer Title</h3>
    <button type="button" class="lt-drawer-close" aria-label="Close"></button>
  </div>
  <div class="lt-drawer-body"></div>
</div>
<div class="lt-overlay" id="my-overlay"></div>

Tabs

<div class="lt-tabs" role="tablist">
  <button type="button" class="lt-tab active" data-tab="tab-one"
          role="tab" aria-selected="true" aria-controls="tab-one">Tab 1</button>
  <button type="button" class="lt-tab" data-tab="tab-two"
          role="tab" aria-selected="false" aria-controls="tab-two">Tab 2</button>
</div>
<div class="lt-tab-panels">
  <div id="tab-one" class="lt-tab-panel active" role="tabpanel"></div>
  <div id="tab-two" class="lt-tab-panel" role="tabpanel"></div>
</div>

JS: lt.tabs.init() — auto-wires all .lt-tabs on page. lt.tabs.switch('tab-two') — programmatic switch.

Accordion

<div class="lt-accordion">
  <button type="button" class="lt-accordion-trigger" aria-expanded="false">
    Section Title
  </button>
  <div class="lt-accordion-content"></div>
</div>

JS: lt.accordion.init() (called by lt.init())

Tooltips

<span class="lt-tooltip-anchor" data-tooltip="Tooltip text">Hover me</span>

JS: lt.tooltip.init() (called by lt.init()). Custom placement via data-tooltip-pos="top|bottom|left|right".

Notifications Bell

<div class="lt-notif-wrap" style="position:relative">
  <button type="button" class="lt-notif-bell-btn" id="notif-bell"
          aria-label="Notifications" aria-expanded="false" aria-haspopup="true">
    🔔
    <span class="lt-notif-badge" id="notif-count" data-count="3"></span>
  </button>
  <div class="lt-notif-panel" id="notif-panel" aria-hidden="true">
    <div class="lt-notif-panel-header">
      <span>Notifications</span>
      <button type="button" class="lt-notif-panel-clear" id="notif-mark-all">Mark all read</button>
    </div>
    <ul class="lt-notif-panel-list" id="notif-list">
      <li class="lt-notif-item lt-notif-item--unread" data-notif-id="1"></li>
    </ul>
    <div class="lt-notif-panel-footer">
      <a href="#" class="lt-link">View all notifications</a>
    </div>
  </div>
</div>

Dropdown Widget

Generic dropdown attached to any trigger button:

<div class="lt-dropdown-wrap" style="position:relative">
  <button type="button" class="lt-btn lt-btn-secondary lt-dropdown-trigger"
          aria-haspopup="true" aria-expanded="false">
    Actions ▾
  </button>
  <div class="lt-dropdown-panel lt-dropdown-panel--right" aria-hidden="true">
    <button type="button" class="lt-dropdown-item">Option 1</button>
    <button type="button" class="lt-dropdown-item">Option 2</button>
    <div class="lt-dropdown-divider"></div>
    <button type="button" class="lt-dropdown-item lt-dropdown-item--danger">Delete</button>
  </div>
</div>

Closes on outside click and Escape (wired in DOMContentLoaded).

Combobox (Searchable Select)

<div class="lt-combobox" id="my-combobox">
  <input type="text" class="lt-combobox-input" id="my-combobox-input"
         placeholder="Search…" autocomplete="off" role="combobox"
         aria-expanded="false" aria-autocomplete="list"
         aria-controls="my-combobox-list">
  <ul class="lt-combobox-list" id="my-combobox-list" role="listbox" aria-hidden="true"></ul>
</div>
lt.combobox.init(inputEl, [
  { value: '1', label: 'Alice' },
  { value: '2', label: 'Bob' },
], {
  onSelect(opt) { console.log(opt.value); },
});

Labels are HTML-escaped before highlight mark insertion (XSS-safe).

Typeahead

<input type="text" class="lt-input" id="my-typeahead"
       placeholder="Type to search…" autocomplete="off">
lt.typeahead.init(inputEl, async (query) => {
  const res = await lt.api.get('/api/search?q=' + encodeURIComponent(query));
  return res.items.map(i => ({ value: i.id, label: i.name }));
}, {
  onSelect(item) { console.log(item); },
  minChars: 2,
  debounce: 200,
});

Wizard / Stepper

<div class="lt-wizard" id="my-wizard">
  <div class="lt-wizard-steps">
    <div class="lt-wizard-step active" data-step="0">Step 1</div>
    <div class="lt-wizard-step" data-step="1">Step 2</div>
    <div class="lt-wizard-step" data-step="2">Step 3</div>
  </div>
  <div class="lt-wizard-panels">
    <div class="lt-wizard-panel active">…Panel 1…</div>
    <div class="lt-wizard-panel">…Panel 2…</div>
    <div class="lt-wizard-panel">…Panel 3…</div>
  </div>
  <div class="lt-wizard-nav">
    <button type="button" class="lt-btn lt-btn-secondary lt-wizard-prev">Back</button>
    <button type="button" class="lt-btn lt-btn-primary lt-wizard-next">Next</button>
  </div>
</div>
const wiz = lt.wizard.init(document.getElementById('my-wizard'), {
  onComplete() { lt.toast.success('Done!'); },
});
wiz.goTo(1); wiz.next(); wiz.prev();

Kanban / Sortable

<div class="lt-grid lt-grid-4">
  <div class="lt-card" id="kanban-col-open">
    <div class="lt-card-header">Open</div>
    <div class="lt-sortable-list" id="kanban-list-open">
      <div class="lt-sortable-item">Card 1</div>
    </div>
  </div>
  <!-- repeat for other columns -->
</div>
['open','progress','review','done'].forEach(col => {
  lt.sortable.init(document.getElementById('kanban-list-' + col), {
    group: 'kanban',   // same group = cross-column drag allowed
    handle: '.lt-drag-handle',
    onSort(listEl) { /* save new order */ },
  });
});

Pagination

<nav class="lt-pagination" id="demo-pagination" aria-label="Page navigation"></nav>
lt.pagination.init(document.getElementById('demo-pagination'), {
  total:    100,
  pageSize: 10,
  current:  1,
  onChange(page) { loadPage(page); },
});

Split Pane

<div class="lt-split-pane" id="my-split">
  <div class="lt-split-top">…top panel…</div>
  <div class="lt-split-divider" aria-label="Resize"
       role="separator" aria-orientation="horizontal"></div>
  <div class="lt-split-bottom">…bottom panel…</div>
</div>
lt.splitPane.init(document.getElementById('my-split'), { minTop: 80, minBottom: 80 });

Lightbox

<img src="thumb.jpg" data-lightbox="path/to/full.jpg"
     data-lightbox-caption="Caption" alt="…">
lt.lightbox.init('[data-lightbox]');

Context Menu

lt.contextMenu.register('[data-context-menu="my-menu"]', [
  { label: 'View Details', action: () => {} },
  { label: 'Copy ID',      action: () => {} },
  { separator: true },
  { label: 'Delete',       action: () => {}, danger: true },
]);

Command Palette

<div id="lt-cmd-overlay" class="lt-cmd-overlay" aria-hidden="true" role="dialog">
  <div class="lt-cmd-box">
    <input id="lt-cmd-input" class="lt-cmd-input" type="text"
           placeholder="Search commands…" autocomplete="off" spellcheck="false">
    <ul class="lt-cmd-results" id="lt-cmd-results" role="listbox"></ul>
  </div>
</div>
lt.cmdPalette.init([
  { id: 'new-ticket',  label: 'New Ticket',    shortcut: 'N', action: () => {} },
  { id: 'go-settings', label: 'Go to Settings', action: () => {} },
]);
// Default binding: Ctrl+K opens the palette

Progress Bars

<div class="lt-progress" data-value="65" data-max="100">
  <div class="lt-progress-bar"></div>
</div>
lt.progress.set(el, 65);
lt.progress.animate(el, 0, 65, 800);

WebSocket Status

<div class="lt-ws-status" id="ws-status" aria-live="polite">
  <span class="lt-ws-dot"></span>
  <span class="lt-ws-label">Connecting…</span>
</div>
const ws = lt.ws.connect('wss://app.example.com/ws', {
  statusEl:       document.getElementById('ws-status'),
  onMessage(data) { console.log(data); },
  onOpen()        { lt.toast.success('Connected'); },
  onClose()       { lt.toast.warning('Disconnected'); },
  reconnect:      true,
  reconnectDelay: 2000,
});
ws.send({ type: 'ping' });
ws.close();

Boot Sequence Overlay

lt.boot.run('MY APP');        // CRT boot animation, auto-dismisses
lt.boot.run('MY APP', true);  // force replay even if already shown this session

Shown once per session. Skipped automatically under prefers-reduced-motion.

Timeline / Log Entries

<div class="lt-timeline">
  <div class="lt-timeline-entry">
    <span class="lt-timeline-dot"></span>
    <div class="lt-timeline-body">
      <span class="lt-timeline-time">14:32</span>
      <span class="lt-timeline-text">Build started</span>
    </div>
  </div>
</div>

Skeleton Loaders

<div class="lt-skeleton lt-skeleton-title"></div>
<div class="lt-skeleton lt-skeleton-text"></div>
<div class="lt-skeleton lt-skeleton-text" style="width:70%"></div>
<div class="lt-skeleton lt-skeleton-avatar"></div>
<div class="lt-skeleton lt-skeleton-btn"></div>

Shimmer animation is disabled under prefers-reduced-motion and on touch devices.

Inline Alert Banners

<div class="lt-alert lt-alert-warning" role="alert">
  <span class="lt-alert-icon"></span>
  <span class="lt-alert-text">Maintenance window at 02:00 UTC</span>
  <button type="button" class="lt-alert-dismiss" aria-label="Dismiss"></button>
</div>

Types: lt-alert-info, lt-alert-success, lt-alert-warning, lt-alert-danger.

JS: lt.alerts.init() wires dismiss buttons (called by lt.init()).


JavaScript API

All modules live under window.lt. Full list:

Module Key Methods
lt.init (opts?) — master initializer
lt.toast success / error / warning / info (msg, ms?)
lt.modal open / close / closeAll
lt.tabs init / switch(panelId)
lt.accordion init / open / close
lt.tooltip init / show / hide
lt.sidebar init
lt.mobileNav open / close / toggle
lt.boot run(name, force?)
lt.keys on / off / initDefaults
lt.clipboard copy(text) → Promise<bool> / initCopyButtons
lt.alerts init / dismiss(el)
lt.progress set / animate
lt.cmdPalette init / open / close / register
lt.validate field / form / showError / clearError / init / custom
lt.tableNav init(tableId)
lt.sortTable init(tableId)
lt.tableColumns init / show / hide / toggle
lt.statsFilter init
lt.autoRefresh start(fn, ms) / stop / now
lt.dropzone init(el, opts)
lt.combobox init(input, options, opts)
lt.typeahead init(input, source, opts)
lt.wizard init(el, opts)
lt.sortable init(listEl, opts)
lt.splitPane init(el, opts)
lt.lightbox init(selector, opts)
lt.contextMenu register(selector, items)
lt.ws connect(url, opts)
lt.pagination init(navEl, opts)
lt.theme toggle / set('light'|'dark') / get
lt.notif set / inc / clear
lt.viewport bp / is / on / off / touch / landscape
lt.bus on / off / emit / once
lt.store set / get / remove / clear / session.*
lt.url params / get / set / remove / setMultiple
lt.api get / post / put / patch / delete(url, body?)
lt.csrf headers()
lt.time ago / uptime / format
lt.bytes format(n)
lt.num format / compact / percent / pad / clamp / lerp
lt.dom el / qs / qsa / on / off / show / hide / toggle
lt.observe lazy(selector) / sentinel(el, cb)
lt.poll (fn, ms, opts?) → { stop }
lt.retry (fn, opts?) → Promise
lt.debounce (fn, wait)
lt.throttle (fn, wait)

API Helper

// GET — no Content-Type header (correct per HTTP spec)
const data = await lt.api.get('/api/tickets');

// POST / PUT / PATCH / DELETE — JSON body + Content-Type
const result = await lt.api.post('/api/tickets', { title: 'Bug', priority: 'high' });
await lt.api.delete('/api/tickets/42');

CSRF token is read from <meta name="csrf-token"> or set via lt.init({ csrf: 'token' }).

Event Bus

lt.bus.on('ticket:created', (ticket) => { refreshTable(); });
lt.bus.emit('ticket:created', { id: 99, title: 'New Bug' });
lt.bus.once('deploy:done', () => lt.toast.success('Deployed!'));
lt.bus.off('ticket:created');

Local / Session Storage

lt.store.set('key', { any: 'value' });  // JSON-serialized
lt.store.get('key');                     // → parsed value or null
lt.store.remove('key');
lt.store.clear();

lt.store.session.set('key', value);      // sessionStorage
lt.store.session.get('key');

Theming

Dark / Light Toggle

<button type="button" class="lt-theme-toggle" id="theme-toggle"
        aria-label="Toggle theme"></button>
lt.theme.toggle();        // flip dark ↔ light
lt.theme.set('light');    // explicitly set
lt.theme.get();           // → 'dark' | 'light'

Theme is persisted to localStorage (lt_theme key). Changes sync across browser tabs instantly via the storage event. The native color-scheme CSS property is also updated so browser UI (scrollbars, form controls) matches the current theme.

CSS Only

Set data-theme="light" on <html> directly. All component styles react through the Section 51 overrides.


Accessibility

  • All interactive elements have role, aria-label, aria-expanded, aria-controls, aria-hidden as appropriate.
  • Focus rings: outline: 2px solid var(--accent-orange) with outline-offset: 2px.
  • Screen-reader-only text: <span class="lt-sr-only">…</span>.
  • Touch targets: all interactive elements are ≥ 44×44px on pointer: coarse devices.
  • prefers-reduced-motion: skeleton shimmer and boot animations are disabled.
  • prefers-color-scheme: theme is auto-detected on first load if no saved preference.
  • Keyboard navigation: arrow keys / Enter in tables, Ctrl+K for command palette, Escape closes modals / drawers / dropdowns.
  • Data tables require <caption class="lt-sr-only">…</caption>.

File Structure

web_template/
├── base.css        Design system styles (79 sections, ~5,200 lines)
├── base.js         Design system JS   (55+ modules, ~2,800 lines)
├── base.html       Living reference template
├── README.md       This file
└── (framework skeletons)
    ├── php/        PHP / Tinker Tickets
    ├── python/     Flask / Jinja2 / GANDALF
    └── node/       Express / EJS / PULSE

LDAP Avatar Integration

lt-avatar components support real profile photos pulled from lldap (the LotusGuild LDAP server). Photos overlay the initials fallback — if a user has no photo the initials show instead; no broken images.

Infrastructure (one-time, per app)

1. Create a service account in lldap

Log into lldap at http://10.10.10.39:17170, or use the GraphQL API:

TOKEN=$(curl -s -X POST http://10.10.10.39:17170/auth/simple/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"<admin>","password":"<pw>"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")

# Create service account
curl -s -X POST http://10.10.10.39:17170/api/graphql \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"query":"mutation { createUser(user: { id: \"my-app\", email: \"my-app@lotusguild.org\", displayName: \"My App Service\" }) { id } }"}'

# Add to lldap_strict_readonly (id=3) for directory read access
curl -s -X POST http://10.10.10.39:17170/api/graphql \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"query":"mutation { addUserToGroup(userId: \"my-app\", groupId: 3) { ok } }"}'

Then set the password via ldappasswd:

ldappasswd -H ldap://10.10.10.39:3890 \
  -D "uid=<admin>,ou=people,dc=example,dc=com" \
  -w '<admin-pw>' \
  -s '<service-account-pw>' \
  "uid=my-app,ou=people,dc=example,dc=com"

2. Install php-ldap and create the avatar cache directory

apt-get install -y php8.2-ldap
mkdir -p /var/www/html/myapp/uploads/avatars
chown -R www-data:www-data /var/www/html/myapp/uploads/avatars

3. Add LDAP config to .env

LDAP_ENABLED=true
LDAP_HOST=10.10.10.39
LDAP_PORT=3890
LDAP_BIND_DN="uid=my-app,ou=people,dc=example,dc=com"
LDAP_BIND_PW="<service-account-pw>"
LDAP_BASE_DN="dc=example,dc=com"
LDAP_USER_BASE="ou=people,dc=example,dc=com"
AVATAR_CACHE_TTL=3600

Note: lldap is currently configured with dc=example,dc=com as the base DN across all services (Authelia, etc.). Do not change this per-app — it requires a coordinated infrastructure migration.

Avatar Endpoint (/api/user_avatar.php)

Copy the reference implementation from tinker_tickets/api/user_avatar.php. It:

  1. Requires a valid session (returns 401 otherwise)
  2. Accepts ?user_id=N and looks up the user's username from the app's DB
  3. Binds to lldap and searches ou=people,dc=example,dc=com with filter (uid={username})
  4. Fetches the avatar attribute (raw binary JPEG, returned as-is by ldap_get_entries())
  5. Validates JPEG magic bytes (\xFF\xD8\xFF) and writes to uploads/avatars/user_{id}.jpg
  6. Writes a .none sentinel file for users with no avatar so lldap is not queried again until TTL expires
  7. Serves the cached file with Content-Type: image/jpeg

CSS — Photo-over-Initials Pattern

base.css (Section 62 — Avatar) provides .lt-avatar-img and .lt-avatar-initials:

.lt-avatar          { position: relative; }
.lt-avatar-initials { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
.lt-avatar-img      { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; border-radius: inherit; z-index: 1; }

The photo sits above the initials. The onerror handler hides the image if the endpoint returns 404 (no avatar), letting the initials show through.

HTML Pattern

<?php
$words    = array_filter(explode(' ', $displayName));
$initials = strtoupper(implode('', array_map(fn($w) => $w[0], array_slice($words, 0, 2))));
$colors   = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
$color    = $colors[abs(crc32($displayName)) % count($colors)];
?>
<div class="lt-avatar lt-avatar--sm <?= $color ?>" aria-hidden="true">
  <?php if ($userId > 0): ?>
    <img src="/api/user_avatar.php?user_id=<?= $userId ?>"
         alt=""
         class="lt-avatar-img"
         onerror="this.style.display='none'">
  <?php endif ?>
  <span class="lt-avatar-initials"><?= htmlspecialchars($initials) ?></span>
</div>

Key points:

  • Always render the initials — they are the fallback, never a broken state
  • The onerror on the <img> hides it when the endpoint returns 404 (no photo set)
  • Color is deterministically derived from the display name so it's consistent across page loads
  • aria-hidden="true" because the avatar is purely decorative; the user's name appears in adjacent text

Known Patterns & Gotchas

Disabled / read-only form elements in display-only contexts

base.css applies aggressive visibility reduction to :disabled and [readonly] form elements:

.lt-input:disabled, .lt-select:disabled, .lt-textarea:disabled,
.lt-input[readonly], .lt-textarea[readonly] {
  opacity: 0.45;
  color: var(--text-muted);  /* #3e607a — ~3.2:1 contrast, fails WCAG AA on dark backgrounds */
}
.lt-checkbox:disabled { opacity: 0.4; }

This is intentional for genuinely disabled controls (submission forms, locked fields). However, if you use disabled purely to make a field non-interactive for display, the result is nearly unreadable on dark/OLED screens.

Pattern: display-only selects / inputs (edit-mode toggle)

A common pattern is disabling fields in view-mode and enabling them in edit-mode. Apply a scoping class and override in your app CSS:

/* In your app's CSS — override base.css fading for display-only fields */
.your-display-field:disabled,
.your-display-field[disabled] {
  opacity: 1;
  color: var(--text-secondary);  /* #7fa3bf — full legibility */
  cursor: default;
  pointer-events: none;
}

Pattern: copy-to-clipboard inputs (readonly)

[readonly] triggers the same opacity: 0.45 rule. For API key / token display fields where the user must read and copy the value, restore opacity inline or via class:

<input type="text" readonly class="lt-input" style="opacity:1;cursor:text">

Pattern: description / content display areas

Avoid rendering multi-line content in a disabled textarea — use a styled <div> instead. Apply white-space: pre-wrap on that div to preserve newlines and multiple spaces (required for ASCII art / diagrams to align correctly, since the body font is already monospace):

.your-description-view {
  white-space: pre-wrap;
  word-break: break-word;
  color: var(--text-primary);
}

Set innerHTML = escHtml(rawText) — no <br> replacement needed when white-space: pre-wrap is active.


Changelog

v1.2 (current)

Design System

  • Font: Courier New → JetBrains Mono (400, 600, 700; italic)
  • Palette: phosphor-green single-accent → 5-accent neon system (Orange #FF6B00, Cyan #00D4FF, Green #00FF88, Red #FF2D55, Purple #BF5FFF)
  • Background: green-tinted CRT → Anduril dark military-tech (#030508#0d1520)
  • Light mode rebuilt from scratch (~200 lines, Section 51): desaturated accent colors, drop-shadow rings instead of neon glows, WCAG-accessible contrast on all 80+ component classes
  • Added --corner-cut, --corner-cut-sm, --corner-cut-lg CSS variables
  • Added --z-panel (10020) to z-index ladder; replaced all hardcoded z-index values
  • Added will-change: transform on .lt-spinner, will-change: opacity on .lt-skeleton
  • Removed duplicate .lt-hidden definition

New Components

  • Notification bell: unread badge, animated dropdown panel, mark-all-read, per-item click-to-read, outside-click close
  • Generic dropdown widget: .lt-dropdown-wrap + .lt-dropdown-panel + .lt-dropdown-trigger
  • Editable ticket detail drawer: form grid, status/priority/assignee selects, description textarea, comment box
  • Advanced filter dropdown for ticket queue
  • Bulk actions dropdown with toast feedback
  • Footer landmark (<footer class="lt-footer">) with copyright

CSS Fixes

  • Responsive table breakpoint: 640px767px
  • Touch targets: 44px minimum on pointer: coarse
  • Breakpoints expanded from 4 to 8 tiers (xs → 4k)
  • Safe-area insets added: env(safe-area-inset-*) in header, nav, footer
  • Kanban forced 2-column at sm/xs breakpoints
  • Toast background: hardcoded rgba → var(--bg-overlay)

JavaScript Fixes

  • Critical: lt.clipboard.initCopyButtons() call fixed (was lt.clipboard.init() — non-existent, crashed entire DOMContentLoaded handler)
  • Critical: Removed duplicate function showToast declaration (JS hoisting caused infinite recursion → silent toasts for all types); progress bar inlined into _displayToast
  • lt.init(): New master initializer with _ltInitialized double-init guard
  • lt.pagination (Module 55): Paginator with ellipsis, prev/next, onChange callback
  • lt.sortable: Shared module-level drag state enables cross-column Kanban drag-and-drop between same-group instances
  • lt.theme: Sets color-scheme CSS property, updates <meta name="theme-color">, cross-tab sync via storage event
  • lt.api: GET requests no longer send Content-Type: application/json (HTTP spec)
  • lt.combobox / lt.typeahead: escHtml() applied before mark-tag insertion (XSS fix)
  • Toast queue: Capped at 12 pending entries

v1.0

Initial release — Courier New, phosphor-green #00ff41, 4-breakpoint responsive, single-accent palette.

S
Description
No description provided
Readme 2.1 MiB
Languages
CSS 41%
JavaScript 29.8%
HTML 25.8%
PHP 1.5%
EJS 1.2%
Other 0.7%