Documents the lldap service account setup, avatar endpoint pattern, CSS photo-over-initials approach, and HTML template used in tinker_tickets. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
34 KiB
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
- Overview
- Quick Start
- Design Tokens
- Breakpoints
- Typography
- Color Palette
- Component Catalog
- JavaScript API
- Theming (Dark / Light)
- Accessibility
- File Structure
- LDAP Avatar Integration
- 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 |
480–767px | 480px |
md |
768–1023px | 768px |
lg |
1024–1279px | 1024px |
xl |
1280–1535px | 1280px |
2xl |
1536–1919px | 1536px |
3xl |
1920–2559px | 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-hiddenas appropriate. - Focus rings:
outline: 2px solid var(--accent-orange)withoutline-offset: 2px. - Screen-reader-only text:
<span class="lt-sr-only">…</span>. - Touch targets: all interactive elements are ≥ 44×44px on
pointer: coarsedevices. 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=comas 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:
- Requires a valid session (returns 401 otherwise)
- Accepts
?user_id=Nand looks up the user'susernamefrom the app's DB - Binds to lldap and searches
ou=people,dc=example,dc=comwith filter(uid={username}) - Fetches the
avatarattribute (raw binary JPEG, returned as-is byldap_get_entries()) - Validates JPEG magic bytes (
\xFF\xD8\xFF) and writes touploads/avatars/user_{id}.jpg - Writes a
.nonesentinel file for users with no avatar so lldap is not queried again until TTL expires - 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
onerroron 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
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-lgCSS variables - Added
--z-panel(10020) to z-index ladder; replaced all hardcoded z-index values - Added
will-change: transformon.lt-spinner,will-change: opacityon.lt-skeleton - Removed duplicate
.lt-hiddendefinition
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 (waslt.clipboard.init()— non-existent, crashed entire DOMContentLoaded handler) - Critical: Removed duplicate
function showToastdeclaration (JS hoisting caused infinite recursion → silent toasts for all types); progress bar inlined into_displayToast lt.init(): New master initializer with_ltInitializeddouble-init guardlt.pagination(Module 55): Paginator with ellipsis, prev/next,onChangecallbacklt.sortable: Shared module-level drag state enables cross-column Kanban drag-and-drop between same-group instanceslt.theme: Setscolor-schemeCSS property, updates<meta name="theme-color">, cross-tab sync viastorageeventlt.api: GET requests no longer sendContent-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.