# LotusGuild Terminal Design System — v1.2
[](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.

> Every Lotus Guild service ([Tinker Tickets](https://code.lotusguild.org/LotusGuild/tinker_tickets), [PULSE](https://code.lotusguild.org/LotusGuild/pulse), [GANDALF](https://code.lotusguild.org/LotusGuild/gandalf)) renders from this one stylesheet and script. No framework, no build step. `base.html` is a living reference page, and the screenshots below are generated from it.
## Gallery
| Dark (default) | Light |
|---|---|
|  |  |
**Kanban view** with priority-coloured cards and per-column frames:

**Command palette** (`Ctrl+K`) with fuzzy filtering and match highlighting:

**Data tables, SLA timers, alerts and progress bars:**





**Component reference** (buttons, badges, chips, status dots, inline messages):

Screenshots are produced with Playwright from `base.html` using demo data only.
---
## 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
```
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
Drawer Title
…
```
### Tabs
```html
…
…
```
JS: `lt.tabs.init()` — auto-wires all `.lt-tabs` on page. `lt.tabs.switch('tab-two')` — programmatic switch.
### Accordion
```html
…
```
JS: `lt.accordion.init()` (called by `lt.init()`)
### Tooltips
```html
Hover me
```
JS: `lt.tooltip.init()` (called by `lt.init()`). Custom placement via `data-tooltip-pos="top|bottom|left|right"`.
### Notifications Bell
```html
Notifications
…
```
### Dropdown Widget
Generic dropdown attached to any trigger button:
```html
```
Closes on outside click and Escape (wired in DOMContentLoaded).
### Combobox (Searchable Select)
```html
```
### Skeleton Loaders
```html
```
Shimmer animation is disabled under `prefers-reduced-motion` and on touch devices.
### Inline Alert Banners
```html
⚠Maintenance window at 02:00 UTC
```
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 / 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 `` 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
```
```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 `` 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: `…`.
- 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 `
…
`.
---
## Starting a New App
### 1. Serve the design system files
Add an nginx alias so every app on the same host can reference the same files:
```nginx
# In each app's server block (or a shared include):
location /web_template/ {
alias /path/to/web_template/;
expires 7d;
add_header Cache-Control "public, immutable";
}
```
Then in your HTML:
```html
```
### 2. Copy the right skeleton
| Stack | Copy this file | Into your app as |
|-------|---------------|-----------------|
| PHP | `php/layout.php` | `views/layout.php` (or `layout_header.php`) |
| Python/Flask | `python/base.html` | `templates/base.html` |
| Node/Express | `node/middleware.js` + `node/layout.ejs` | `middleware.js` + `views/layout.ejs` |
### 3. Define your nav
**PHP** — pass `$navLinks` before including the layout:
```php
$navLinks = [
['href' => '/', 'key' => 'dashboard', 'label' => 'Dashboard'],
['href' => '/reports', 'key' => 'reports', 'label' => 'Reports'],
['label' => 'Admin', 'key' => 'admin', 'adminOnly' => true, 'children' => [
['href' => '/admin/users', 'label' => 'Users'],
]],
];
$activeNav = 'dashboard';
include __DIR__ . '/views/layout.php';
```
**Python/Flask** — inject via context processor or pass directly to `render_template`:
```python
nav_links = [
{'href': url_for('index'), 'key': 'dashboard', 'label': 'Dashboard'},
{'href': url_for('settings'), 'key': 'settings', 'label': 'Settings'},
]
return render_template('page.html', nav_links=nav_links)
```
**Node/Express** — set on `res.locals` via `injectLocals` middleware:
```js
app.use((req, res, next) => {
res.locals.navLinks = [
{ href: '/', key: 'dashboard', label: 'Dashboard' },
{ href: '/workers', key: 'workers', label: 'Workers' },
];
next();
});
```
### 4. Add app-specific CSS
Create `app.css` in your app. Import nothing from `base.css` — just override tokens or add components:
```css
/* app.css — app-specific extensions only */
:root {
--app-accent: #FF6B00; /* override if needed */
}
/* Only put styles here that aren't already in base.css */
```
### 5. Initialise
In your base template, after `base.js`:
```html
```
---
## 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 component reference — open in browser to browse everything
├── README.md This file
├── AUTHELIA_INTEGRATION.md Theming Authelia portal with this design system
└── framework skeletons/
├── php/
│ └── layout.php Generic PHP base layout (pass $navLinks)
├── python/
│ ├── base.html Jinja2 base template (pass nav_links list)
│ └── auth.py Authelia SSO helper for Flask
└── node/
├── middleware.js Express middleware (auth, CSRF, nonce, rate limit)
└── layout.ejs EJS base template (uses res.locals.navLinks)
```
---
## 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://lldap.example.internal:17170`, or use the GraphQL API:
```bash
TOKEN=$(curl -s -X POST http://lldap.example.internal:17170/auth/simple/login \
-H 'Content-Type: application/json' \
-d '{"username":"","password":""}' | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
# Create service account
curl -s -X POST http://lldap.example.internal: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://lldap.example.internal: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://lldap.example.internal:3890 \
-D "uid=,ou=people,dc=example,dc=com" \
-w '' \
-s '' \
"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=lldap.example.internal
LDAP_PORT=3890
LDAP_BIND_DN="uid=my-app,ou=people,dc=example,dc=com"
LDAP_BIND_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
$w[0], array_slice($words, 0, 2))));
$colors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
$color = $colors[abs(crc32($displayName)) % count($colors)];
?>
0): ?>
= htmlspecialchars($initials) ?>
```
Key points:
- Always render the initials — they are the fallback, never a broken state
- The `onerror` on the `` 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
```
**Pattern: description / content display areas**
Avoid rendering multi-line content in a `disabled` textarea — use a styled `
` 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 ` ` 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 (`