HTML: - Add type="button" to all remaining buttons (nav drawer close, menu btn, theme toggle, notif bell, right drawer close, tab buttons, sidebar toggle, alert close x4, code copy, tab bar buttons x4, detail panel open, modal close x2, keyboard shortcuts close) - Add aria-label="Search commands" to command palette input - Notification panel close(true): restore focus to bell on Escape - Generic dropdowns: add Escape key handler with trigger focus restore CSS: - Add a:focus-visible global focus ring - Add .lt-nav-dropdown-menu li a:focus-visible - Add .lt-markdown a:focus-visible - Fix dead .lt-typeahead-option selector → .lt-typeahead-item with :hover, .is-focused, :focus-visible for light theme Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
LotusGuild Terminal Design System v1.0
Unified layout, styling, and JavaScript utilities for all LotusGuild web applications.
Applies to: Tinker Tickets (PHP) · PULSE (Node.js) · GANDALF (Flask)
Quick Start
Core files (needed by every app):
base.css → all variables, components, animations, responsive rules
base.js → toast, modal, tabs, CSRF, fetch helpers, keyboard shortcuts
base.html → full component reference (static demo, not for production)
Platform-specific helpers (one per backend):
php/layout.php → PHP base layout with CSP nonce + CSRF injection
python/auth.py → Flask @require_auth / @require_admin decorators
python/base.html → Jinja2 base template with block inheritance
node/middleware.js → Express requireAuth, csrfMiddleware, cspNonce, etc.
Then load them in your HTML:
<link rel="stylesheet" href="/web_template/base.css">
<!-- app-specific overrides after: -->
<link rel="stylesheet" href="/assets/css/app.css">
<script src="/web_template/base.js"></script>
<!-- app-specific JS after: -->
<script nonce="NONCE" src="/assets/js/app.js?v=YYYYMMDD"></script>
Aesthetic at a Glance
| Property | Value |
|---|---|
| Background | #0a0a0a (near-black) |
| Primary accent | #00ff41 (phosphor green) |
| Secondary accent | #ffb000 (amber) |
| Tertiary accent | #00ffff (cyan) |
| Error / critical | #ff4444 (red) |
| Font | 'Courier New', 'Consolas', 'Monaco', 'Menlo', monospace |
| Border radius | 0 — no rounded corners ever |
| Box shadow | Glow halos only (0 0 Xpx color) — no drop shadows |
| Borders | 2px solid standard · 3px double for outer frames / modals |
| CRT effects | Scanline animation · screen flicker at 30s · binary corner watermark |
CSS Prefix
All classes use the .lt- prefix (LotusGuild Terminal) to prevent collisions with app-specific CSS.
.lt-btn /* buttons */
.lt-card /* cards */
.lt-frame /* ASCII outer frames */
.lt-modal-overlay /* modals */
.lt-toast /* toasts */
.lt-status-* /* status badges */
.lt-p1 … .lt-p5 /* priority badges */
/* etc. */
CSS Custom Properties
All colour, spacing, and layout values are CSS custom properties on :root.
Override them per-app in your app.css:
/* Example: app-specific accent on top of base */
:root {
--terminal-amber: #ffd000; /* slightly brighter amber */
}
Key Variables
/* Backgrounds */
--bg-primary: #0a0a0a
--bg-secondary: #1a1a1a
--bg-tertiary: #2a2a2a
--bg-terminal: #001a00
/* Terminal colours */
--terminal-green: #00ff41
--terminal-amber: #ffb000
--terminal-cyan: #00ffff
--terminal-red: #ff4444
/* Glow stacks (text-shadow) */
--glow-green
--glow-green-intense
--glow-amber
--glow-amber-intense
--glow-cyan
--glow-red
/* Box-shadow halos */
--box-glow-green
--box-glow-amber
--box-glow-red
/* Z-index ladder */
--z-dropdown: 100 --z-modal: 500 --z-toast: 800 --z-overlay: 9999
Components
Buttons
<button class="lt-btn">Default</button>
<button class="lt-btn lt-btn-primary">Primary</button>
<button class="lt-btn lt-btn-danger">Delete</button>
<button class="lt-btn lt-btn-sm">Small</button>
<button class="lt-btn lt-btn-ghost">Ghost</button>
<a href="/create" class="lt-btn lt-btn-primary">Link Button</a>
All buttons:
- Transparent background, green border,
[ text ]bracket decoration via::before/::after - Hover → amber colour,
translateY(-2px)lift, amber glow border-radius: 0— terminal style, no rounding
Status Badges
<span class="lt-status lt-status-open">Open</span>
<span class="lt-status lt-status-in-progress">In Progress</span>
<span class="lt-status lt-status-closed">Closed</span>
<span class="lt-status lt-status-online">Online</span>
<span class="lt-status lt-status-failed">Failed</span>
Spinning ◐ indicator on lt-status-in-progress and lt-status-running.
Priority Badges
<span class="lt-priority lt-p1">P1 Critical</span>
<span class="lt-priority lt-p2">P2 High</span>
<span class="lt-priority lt-p3">P3 Med</span>
<span class="lt-priority lt-p4">P4 Low</span>
<span class="lt-priority lt-p5">P5 Min</span>
P1 has a pulsing glow animation.
Chips (compact inline status)
<span class="lt-chip lt-chip-ok">OK</span>
<span class="lt-chip lt-chip-warn">Warn</span>
<span class="lt-chip lt-chip-critical">Critical</span>
<span class="lt-chip lt-chip-info">Info</span>
Inline Messages
<div class="lt-msg lt-msg-error">Something went wrong</div>
<div class="lt-msg lt-msg-success">Saved successfully</div>
<div class="lt-msg lt-msg-warning">Rate limit approaching</div>
<div class="lt-msg lt-msg-info">Auto-refresh every 30s</div>
ASCII Frames
Outer frame (double-line, for major sections):
<div class="lt-frame">
<span class="lt-frame-bl">╚</span>
<span class="lt-frame-br">╝</span>
<div class="lt-section-header">Section Title</div>
<div class="lt-section-body">
<!-- content -->
</div>
</div>
Inner frame (single-line, for sub-panels):
<div class="lt-frame-inner">
<div class="lt-subsection-header">Sub Section</div>
<!-- content -->
</div>
The ::before / ::after pseudo-elements draw the top-left ╔/┌ and top-right ╗/┐ corners automatically. Bottom corners require explicit <span> children because CSS cannot target :nth-pseudo-element.
Cards
<div class="lt-card">
<div class="lt-card-title">Card Title</div>
<!-- content -->
</div>
<!-- Grid of cards -->
<div class="lt-grid"> <!-- auto-fill, min 300px -->
<div class="lt-grid-2"> <!-- 2 columns -->
<div class="lt-grid-3"> <!-- 3 columns -->
<div class="lt-grid-4"> <!-- 4 columns -->
Tables
Full-border table (simple data, terminal look):
<div class="lt-table-wrap">
<table class="lt-table" id="my-table">
<thead><tr>
<th data-sort-key="name">Name</th>
<th data-sort-key="status">Status</th>
</tr></thead>
<tbody>
<tr class="lt-row-p1 lt-row-critical">
<td>ticket-001</td>
<td><span class="lt-status lt-status-open">Open</span></td>
</tr>
</tbody>
</table>
</div>
<!-- Wire sorting -->
<script>lt.sortTable.init('my-table');</script>
Data table (compact, row-only separators, for dense data):
<table class="lt-data-table">…</table>
Row classes: lt-row-p1–lt-row-p5 (priority left border) · lt-row-critical · lt-row-warning · lt-row-selected (keyboard nav highlight)
Forms
<div class="lt-form-group">
<label class="lt-label lt-label-required" for="title">Title</label>
<input id="title" type="text" class="lt-input" placeholder="…">
<span class="lt-form-hint">Markdown supported.</span>
</div>
<div class="lt-form-group">
<label class="lt-label" for="priority">Priority</label>
<select id="priority" class="lt-select">
<option>P1 — Critical</option>
</select>
</div>
<div class="lt-form-group">
<label class="lt-label">
<input type="checkbox" class="lt-checkbox"> Confidential
</label>
</div>
<!-- Search with prompt prefix -->
<div class="lt-search">
<input type="search" class="lt-input lt-search-input" placeholder="Search…">
</div>
Labels are amber-coloured and uppercase. Inputs: green border, focus → amber border + glow pulse.
Modals
<!-- Trigger -->
<button class="lt-btn" data-modal-open="my-modal">Open Modal</button>
<!-- Modal -->
<div id="my-modal" class="lt-modal-overlay" aria-hidden="true">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title">Modal Title</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body">
<!-- content -->
</div>
<div class="lt-modal-footer">
<button class="lt-btn lt-btn-ghost" data-modal-close>Cancel</button>
<button class="lt-btn lt-btn-primary">Confirm</button>
</div>
</div>
</div>
data-modal-open="id"→ opens the overlaydata-modal-close→ closes the nearest.lt-modal-overlay- Click on backdrop (
.lt-modal-overlay) → closes automatically - ESC key → closes all open modals
Tabs
<div class="lt-tabs">
<button class="lt-tab active" data-tab="panel-a">Tab A</button>
<button class="lt-tab" data-tab="panel-b">Tab B</button>
</div>
<div id="panel-a" class="lt-tab-panel active">…</div>
<div id="panel-b" class="lt-tab-panel">…</div>
<script>lt.tabs.init();</script>
Active tab persists across page reloads via localStorage.
Toast Notifications
lt.toast.success('Ticket #123 saved');
lt.toast.error('Connection refused', 5000); // custom duration ms
lt.toast.warning('Rate limit 80% used');
lt.toast.info('Auto-refresh triggered');
Toasts appear bottom-right, stack vertically, auto-dismiss (default 3.5s), include an audible beep.
Stats Widgets
<div class="lt-stats-grid">
<div class="lt-stat-card" data-filter-key="status" data-filter-val="Open">
<span class="lt-stat-icon">📋</span>
<div class="lt-stat-info">
<span class="lt-stat-value">42</span>
<span class="lt-stat-label">Open</span>
</div>
</div>
</div>
Clicking a stat card calls window.lt_onStatFilter(key, val) — implement this function in your app JS to wire it to your filter logic.
Sidebar
<aside class="lt-sidebar" id="lt-sidebar">
<div class="lt-sidebar-header">
Filters
<button class="lt-sidebar-toggle" data-sidebar-toggle="lt-sidebar">◀</button>
</div>
<div class="lt-sidebar-body">
<div class="lt-filter-group">
<span class="lt-filter-label">Category</span>
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox"> Hardware
</label>
</div>
</div>
</aside>
Collapse state persists across page reloads via sessionStorage.
Boot Sequence
<div id="lt-boot" class="lt-boot-overlay" data-app-name="MY APP" style="display:none">
<pre id="lt-boot-text" class="lt-boot-text"></pre>
</div>
Runs once per session. Shows animated ASCII boot sequence. Add data-app-name to customise the banner text.
JavaScript API (window.lt)
/* HTML safety */
lt.escHtml(str) // escape for innerHTML
/* Toasts */
lt.toast.success(msg, durationMs?)
lt.toast.error(msg, durationMs?)
lt.toast.warning(msg, durationMs?)
lt.toast.info(msg, durationMs?)
/* Audio */
lt.beep('success' | 'error' | 'info') // Web Audio API beep; silent-fails if unavailable
// Note: toasts automatically call lt.beep() — only call directly for non-toast events
/* Modals */
lt.modal.open('modal-id')
lt.modal.close('modal-id')
lt.modal.closeAll()
/* Tabs */
lt.tabs.init()
lt.tabs.switch('panel-id')
/* Boot */
lt.boot.run('APP NAME', forceFlag?)
/* Keyboard shortcuts */
lt.keys.on('ctrl+s', handler)
lt.keys.off('ctrl+s')
lt.keys.initDefaults() // ESC close, Ctrl+K focus search, ? help
/* Sidebar */
lt.sidebar.init()
/* CSRF */
lt.csrf.headers() // returns { 'X-CSRF-Token': token } or {}
/* Fetch helpers */
await lt.api.get('/api/tickets')
await lt.api.post('/api/update_ticket.php', { id: 1, status: 'Closed' })
await lt.api.put('/api/workflows/5', payload)
await lt.api.delete('/api/comment', { id: 9 })
// All throw Error on non-2xx with the server's error message
/* Time */
lt.time.ago(isoString) // "5m ago"
lt.time.uptime(seconds) // "14d 6h 3m"
lt.time.format(isoString) // locale datetime string
/* Bytes */
lt.bytes.format(1234567) // "1.18 MB"
/* Table utilities — must be called explicitly (not auto-initialized) */
lt.tableNav.init('table-id') // j/k/Enter keyboard navigation on <tbody> rows
lt.sortTable.init('table-id') // click-to-sort on <th data-sort-key> headers
/* Stats widget filtering */
lt.statsFilter.init() // wires data-filter-key clicks
/* Auto-refresh */
lt.autoRefresh.start(fn, 30000) // call fn every 30 s
lt.autoRefresh.stop()
lt.autoRefresh.now() // trigger immediately + restart timer
CSRF Integration
PHP (Tinker Tickets)
<!-- In your view, using SecurityHeadersMiddleware nonce: -->
<script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
window.APP_TIMEZONE = '<?php echo $config['TIMEZONE']; ?>';
</script>
lt.api.* automatically includes the token as X-CSRF-Token header.
Node.js (PULSE)
// server.js: generate token and pass to template
res.render('index', { csrfToken: req.session.csrfToken });
<!-- EJS template -->
<script nonce="<%= nonce %>">window.CSRF_TOKEN = '<%= csrfToken %>';</script>
Flask (GANDALF)
GANDALF uses Authelia SSO with SameSite=Strict cookies so CSRF tokens are not required on API endpoints. For new apps using Flask-WTF:
<script nonce="{{ nonce }}">window.CSRF_TOKEN = '{{ csrf_token() }}';</script>
Auth Integration Patterns
PHP — Authelia headers
// AuthMiddleware.php
$user = [
'username' => $_SERVER['HTTP_REMOTE_USER'] ?? '',
'name' => $_SERVER['HTTP_REMOTE_NAME'] ?? '',
'email' => $_SERVER['HTTP_REMOTE_EMAIL'] ?? '',
'groups' => explode(',', $_SERVER['HTTP_REMOTE_GROUPS'] ?? ''),
'is_admin' => in_array('admin', explode(',', $_SERVER['HTTP_REMOTE_GROUPS'] ?? '')),
];
Python/Flask — @require_auth decorator
def require_auth(f):
@wraps(f)
def wrapper(*args, **kwargs):
user = request.headers.get('Remote-User', '')
if not user:
return '401 Not Authenticated', 401
groups = [g.strip() for g in request.headers.get('Remote-Groups','').split(',')]
if 'admin' not in groups:
return '403 Forbidden', 403
return f(*args, **kwargs)
return wrapper
Node.js — Express middleware
function requireAuth(req, res, next) {
const user = req.headers['remote-user'];
if (!user) return res.status(401).json({ error: 'Not authenticated' });
req.user = {
username: user,
name: req.headers['remote-name'] || user,
email: req.headers['remote-email'] || '',
groups: (req.headers['remote-groups'] || '').split(',').map(s => s.trim()),
isAdmin: (req.headers['remote-groups'] || '').split(',').includes('admin'),
};
next();
}
Framework Skeleton Files
| File | Framework | Description |
|---|---|---|
php/layout.php |
PHP / Tinker Tickets | Complete page layout with CSP nonce, CSRF, session |
python/base.html |
Flask / Jinja2 | Jinja2 template extending pattern |
python/auth.py |
Flask | @require_auth decorator + _get_user() |
node/middleware.js |
Node.js / Express | Auth + CSRF + CSP nonce middleware |
Directory Layout for New Apps
my-app/
├── assets/
│ ├── css/
│ │ └── app.css # Extends base.css — app-specific rules only
│ ├── js/
│ │ └── app.js # App logic — uses lt.* from base.js
│ └── images/
│ └── favicon.png
├── (php|python|node) files…
└── README.md # Link back to web_template/README.md for styling docs
Responsive Breakpoints
| Breakpoint | Width | Changes |
|---|---|---|
| XL | > 1400px | Full layout, 4-col grids |
| LG | ≤ 1400px | 3-col grids, 4-col stats |
| MD | ≤ 1200px | 2-col grids, narrow sidebar |
| SM (tablet) | ≤ 1024px | Sidebar stacks below content |
| XS (phone) | ≤ 768px | Nav hidden, single-col |
| XXS | ≤ 480px | Minimal padding, 2-col stats |
Key Design Rules
- No rounded corners —
border-radius: 0everywhere - No drop shadows — only glow halos (
box-shadow: 0 0 Xpx color) - No sans-serif fonts — 100% monospace stack
- ASCII borders — box-drawing chars via
::before/::after, never images - All uppercase — headings, labels, nav links, button text
[ brackets ]— buttons and nav links wrap their text in[ ]- Green = primary — informational / active state
- Amber = secondary — headings, highlights, hover state
- Cyan = tertiary — uplinks, info, POE active, prompts
- Red = critical — errors, P1 priority, offline state
- Glow on everything critical —
text-shadowglow stacks for P1 and errors - Amber form labels — labels are amber, inputs are green
- Scanlines always on — the
body::beforeCRT overlay is non-negotiable
Accessibility Notes
- All interactive elements have
:focus-visibleoutlines (amber, 2px) @media (prefers-reduced-motion)disables all animations- Screen-reader utility class:
.lt-sr-only - Modals set
aria-hiddenand trap focus - Status elements use semantic colour + text (not colour alone)
- Minimum contrast: text-muted
#00bb33on#0a0a0a≈ 5.3:1 (WCAG AA ✓)