Files
web_template/README.md
T

1124 lines
36 KiB
Markdown
Raw Normal View History

# LotusGuild Terminal Design System — v1.2
2026-04-14 16:28:23 -04:00
[![Lint](https://code.lotusguild.org/LotusGuild/web_template/actions/workflows/lint.yml/badge.svg)](https://code.lotusguild.org/LotusGuild/web_template/actions?workflow=lint.yml)
**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](#overview)
2. [Quick Start](#quick-start)
3. [Design Tokens](#design-tokens)
4. [Breakpoints](#breakpoints)
5. [Typography](#typography)
6. [Color Palette](#color-palette)
7. [Component Catalog](#component-catalog)
8. [JavaScript API](#javascript-api)
9. [Theming (Dark / Light)](#theming)
10. [Accessibility](#accessibility)
11. [File Structure](#file-structure)
12. [LDAP Avatar Integration](#ldap-avatar-integration)
13. [Changelog](#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
```html
<!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.
```js
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:
```js
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:
```html
<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
```html
<!-- 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:
```html
<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
```html
<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
```html
<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
```html
<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:
```js
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
```html
<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
```html
<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:
```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
```html
<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
```js
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)
```html
<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
```html
<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
```html
<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
```html
<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
```html
<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:
```html
<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)
```html
<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>
```
```js
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
```html
<input type="text" class="lt-input" id="my-typeahead"
placeholder="Type to search…" autocomplete="off">
```
```js
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
```html
<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>
```
```js
const wiz = lt.wizard.init(document.getElementById('my-wizard'), {
onComplete() { lt.toast.success('Done!'); },
});
wiz.goTo(1); wiz.next(); wiz.prev();
```
### Kanban / Sortable
```html
<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>
```
```js
['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
```html
<nav class="lt-pagination" id="demo-pagination" aria-label="Page navigation"></nav>
```
```js
lt.pagination.init(document.getElementById('demo-pagination'), {
total: 100,
pageSize: 10,
current: 1,
onChange(page) { loadPage(page); },
});
```
### Split Pane
```html
<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>
```
```js
lt.splitPane.init(document.getElementById('my-split'), { minTop: 80, minBottom: 80 });
```
### Lightbox
```html
<img src="thumb.jpg" data-lightbox="path/to/full.jpg"
data-lightbox-caption="Caption" alt="…">
```
```js
lt.lightbox.init('[data-lightbox]');
```
### Context Menu
```js
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
```html
<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>
```
```js
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
```html
<div class="lt-progress" data-value="65" data-max="100">
<div class="lt-progress-bar"></div>
</div>
```
```js
lt.progress.set(el, 65);
lt.progress.animate(el, 0, 65, 800);
```
### WebSocket Status
```html
<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>
```
```js
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
```js
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
```html
<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
```html
<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
```html
<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
```js
// 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
```js
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
```js
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
```html
<button type="button" class="lt-theme-toggle" id="theme-toggle"
aria-label="Toggle theme"></button>
```
```js
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:
```bash
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`:
```bash
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**
```bash
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`**
```ini
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`:
```css
.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
<?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:
```css
.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:
```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:
```html
<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):
```css
.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: `640px``767px`
- 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.