Files
web_template/README.md
T
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

1122 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](#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.