Initial commit: LotusGuild Terminal Design System v1.0

Unified CSS, JavaScript utilities, HTML template, and framework skeleton
files for Tinker Tickets (PHP), PULSE (Node.js), and GANDALF (Flask).

Includes aesthetic_diff.md documenting every divergence between the three
apps with prioritised recommendations for convergence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-14 21:08:57 -04:00
commit 66538f9ad8
9 changed files with 4895 additions and 0 deletions

589
README.md Normal file
View File

@@ -0,0 +1,589 @@
# 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
Copy three files into your app's static/public directory:
```
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)
```
Then load them in your HTML:
```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.
```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`:
```css
/* Example: app-specific accent on top of base */
:root {
--terminal-amber: #ffd000; /* slightly brighter amber */
}
```
### Key Variables
```css
/* 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
```html
<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
```html
<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
```html
<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)
```html
<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
```html
<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):
```html
<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):
```html
<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
```html
<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):
```html
<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):
```html
<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
```html
<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
```html
<!-- 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 overlay
- `data-modal-close` → closes the nearest `.lt-modal-overlay`
- Click on backdrop (`.lt-modal-overlay`) → closes automatically
- ESC key → closes all open modals
### Tabs
```html
<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
```js
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
```html
<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
```html
<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
```html
<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`)
```js
/* 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?)
/* 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 */
lt.tableNav.init('table-id') // j/k/Enter keyboard navigation
lt.sortTable.init('table-id') // click-to-sort on 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)
```php
<!-- 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)
```js
// server.js: generate token and pass to template
res.render('index', { csrfToken: req.session.csrfToken });
```
```html
<!-- 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:
```html
<script nonce="{{ nonce }}">window.CSRF_TOKEN = '{{ csrf_token() }}';</script>
```
---
## Auth Integration Patterns
### PHP — Authelia headers
```php
// 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
```python
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
```js
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
1. **No rounded corners**`border-radius: 0` everywhere
2. **No drop shadows** — only glow halos (`box-shadow: 0 0 Xpx color`)
3. **No sans-serif fonts** — 100% monospace stack
4. **ASCII borders** — box-drawing chars via `::before`/`::after`, never images
5. **All uppercase** — headings, labels, nav links, button text
6. **`[ brackets ]`** — buttons and nav links wrap their text in `[ ]`
7. **Green = primary** — informational / active state
8. **Amber = secondary** — headings, highlights, hover state
9. **Cyan = tertiary** — uplinks, info, POE active, prompts
10. **Red = critical** — errors, P1 priority, offline state
11. **Glow on everything critical**`text-shadow` glow stacks for P1 and errors
12. **Amber form labels** — labels are amber, inputs are green
13. **Scanlines always on** — the `body::before` CRT overlay is non-negotiable
---
## Accessibility Notes
- All interactive elements have `:focus-visible` outlines (amber, 2px)
- `@media (prefers-reduced-motion)` disables all animations
- Screen-reader utility class: `.lt-sr-only`
- Modals set `aria-hidden` and trap focus
- Status elements use semantic colour + text (not colour alone)
- Minimum contrast: text-muted `#00bb33` on `#0a0a0a` ≈ 5.3:1 (WCAG AA ✓)

437
aesthetic_diff.md Normal file
View File

@@ -0,0 +1,437 @@
# LotusGuild Aesthetic Diff — Convergence Guide
Cross-app analysis of every divergence between Tinker Tickets (PHP),
PULSE (Node.js), and GANDALF (Flask) that prevents a unified aesthetic.
Each section lists the current state in each app, then the **target state**
from the unified design system (`web_template/base.css`).
---
## 1. CSS Custom Property Names
The biggest source of divergence. Gandalf uses entirely different variable names.
| Property | Tinker Tickets | PULSE | GANDALF | **Target** |
|---|---|---|---|---|
| Main background | `--bg-primary: #0a0a0a` | `--bg-primary: #0a0a0a` | `--bg: #0a0a0a` | `--bg-primary` |
| Surface | `--bg-secondary: #1a1a1a` | `--bg-secondary: #1a1a1a` | `--bg2: #1a1a1a` | `--bg-secondary` |
| Raised surface | `--bg-tertiary: #2a2a2a` | `--bg-tertiary: #2a2a2a` | `--bg3: #2a2a2a` | `--bg-tertiary` |
| Primary green | `--terminal-green: #00ff41` | `--terminal-green: #00ff41` | `--green: #00ff41` | `--terminal-green` |
| Green tint | `--terminal-green-dim` | `--terminal-green-dim` | `--green-dim: rgba(0,255,65,.15)` | `--terminal-green-dim` |
| Amber | `--terminal-amber: #ffb000` | `--terminal-amber: #ffb000` | `--amber: #ffb000` | `--terminal-amber` |
| Amber tint | `--terminal-amber-dim` | `--terminal-amber-dim` | `--amber-dim` | `--terminal-amber-dim` |
| Cyan | `--terminal-cyan: #00ffff` | `--terminal-cyan: #00ffff` | `--cyan: #00ffff` | `--terminal-cyan` |
| Red | `--terminal-red: #ff4444` | `--terminal-red: #ff4444` | `--red: #ff4444` | `--terminal-red` |
| Body text | `--text-primary` | `--text-primary` | `--text` | `--text-primary` |
| Dim text | `--text-secondary` | `--text-secondary` | `--text-dim` | `--text-secondary` |
| Muted text | `--text-muted: #00bb33` | `--text-muted: #008822` | `--text-muted: #00bb33` | `--text-muted: #00bb33` |
| Border | `--border-color` | `--border-color` | `--border: rgba(0,255,65,.35)` | `--border-color` |
| Glow (green) | `--glow-green` | `--glow-green` | `--glow` | `--glow-green` |
| Glow (amber) | `--glow-amber` | `--glow-amber` | `--glow-amber` | `--glow-amber` ✓ |
| Font | `--font-mono` | `--font-mono` | `--font` | `--font-mono` |
### Fix for GANDALF (`style.css`)
Add these aliases at the top of `:root` (keeps existing rules working):
```css
:root {
/* Aliases to match unified naming */
--bg-primary: var(--bg);
--bg-secondary: var(--bg2);
--bg-tertiary: var(--bg3);
--terminal-green: var(--green);
--terminal-green-dim:var(--green-dim);
--terminal-amber: var(--amber);
--terminal-amber-dim:var(--amber-dim);
--terminal-cyan: var(--cyan);
--terminal-red: var(--red);
--text-primary: var(--text);
--text-secondary: var(--text-dim);
--border-color: var(--border);
--glow-green: var(--glow);
--font-mono: var(--font);
--text-muted: #00bb33; /* override GANDALF's #008822 — too dark */
}
```
Then migrate GANDALF's new code to use unified names. Remove aliases on next major refactor.
---
## 2. Border Width & Style
| Context | Tinker Tickets | PULSE | GANDALF | **Target** |
|---|---|---|---|---|
| Standard card / panel | `2px solid` | `2px solid` | `1px solid` | `2px solid` |
| Modal | `3px double` | `3px double` | `1px solid` | `3px double` |
| Table outer | `2px solid` | — | none | `2px solid` |
| Input fields | `2px solid` | `2px solid` | `1px solid` | `2px solid` |
| Button | `2px solid` | `2px solid` | `1px solid` (`.btn-sm`) | `2px solid` |
**Change required in GANDALF:** Increase `.modal`, `.host-card`, `.data-table`, `.form-group input/select`, `.btn-sm` border widths from `1px` to `2px`. The `1px` borders make elements look fragile compared to the other apps.
```css
/* GANDALF style.css — search & replace */
.modal { border: 1px solid border: 3px double }
.host-card { border: 1px solid border: 2px solid }
.form-group input,
.form-group select { border: 1px solid border: 2px solid }
.btn-sm { border: 1px solid border: 2px solid }
```
---
## 3. Button Pattern
| Aspect | Tinker Tickets | PULSE | GANDALF | **Target** |
|---|---|---|---|---|
| Bracket decoration | `[ text ]` via `::before`/`::after` | `[ text ]` via `::before`/`::after` | `> text` (primary only), no brackets on most buttons | `[ text ]` via `::before`/`::after` |
| Hover transform | `translateY(-2px)` | `translateY(-2px)` | none | `translateY(-2px)` |
| Hover animation | `pulse-glow-box 1.5s infinite` | none | none | none (lift is sufficient) |
| Padding (standard) | `10px 20px` | `12px 24px` | `6px 14px` (`.btn`) | `10px 20px` |
| Padding (small) | `5px 10px` | `6px 12px` | `2px 8px` | `5px 10px` |
| Font size | `0.9rem` | `1em` | `0.72em0.8em` | `0.9rem` |
| Text transform | uppercase | uppercase | uppercase | uppercase |
**Change required in GANDALF:** Add `::before`/`::after` bracket decorations to all `.btn`, `.btn-primary`, `.btn-secondary`, `.btn-danger` classes. Add `translateY(-2px)` hover transform. Increase padding.
```css
/* GANDALF — add to existing .btn rules */
.btn::before { content: '[ '; }
.btn::after { content: ' ]'; }
.btn:hover { transform: translateY(-2px); }
/* Adjust .btn-primary::before to '> ' instead of '[ ' for visual differentiation */
.btn-primary::before { content: '> '; }
```
---
## 4. Glow Definition Consistency
| App | Green glow | Amber glow |
|---|---|---|
| Tinker Tickets | `0 0 5px #00ff41, 0 0 10px #00ff41, 0 0 15px #00ff41` (3-layer solid) | `0 0 5px #ffb000, 0 0 10px #ffb000, 0 0 15px #ffb000` |
| PULSE | Identical to Tinker Tickets | Identical |
| GANDALF | `0 0 5px #00ff41, 0 0 10px rgba(0,255,65,.4)` (2-layer, rgba 2nd) | `0 0 5px #ffb000, 0 0 10px rgba(255,176,0,.4)` |
GANDALF's 2-layer glow is slightly softer. Both look fine, but they appear different on the same screen if pages are compared side-by-side.
**Change required in GANDALF:** Update glow definitions to match the 3-layer solid stack:
```css
/* GANDALF style.css */
:root {
--glow: 0 0 5px #00ff41, 0 0 10px #00ff41, 0 0 15px #00ff41;
--glow-xl: 0 0 8px #00ff41, 0 0 16px #00ff41, 0 0 24px #00ff41, 0 0 32px rgba(0,255,65,.5);
--glow-amber: 0 0 5px #ffb000, 0 0 10px #ffb000, 0 0 15px #ffb000;
}
```
---
## 5. Section Header Pattern
| App | Syntax | Example |
|---|---|---|
| Tinker Tickets | `╠═══ TITLE ═══╣` | Symmetric, double-bar bookends |
| PULSE | `═══ TITLE ═══` (via h3::before/after) | No `╠` character |
| GANDALF | `╠══ TITLE` (one-sided) | Only left bookend, no right |
**Change required in PULSE and GANDALF:** Standardise to `╠═══ TITLE ═══╣`.
PULSE (`index.html`, all `h3::before`/`h3::after`):
```css
/* PULSE: update h3 pseudo-elements */
h3::before { content: '╠═══ '; color: var(--terminal-green); }
h3::after { content: ' ═══╣'; color: var(--terminal-green); }
```
GANDALF (`style.css`, `.section-title`):
```css
/* GANDALF: add right bookend */
.section-title::after { content: ' ═══╣'; color: var(--green); }
```
---
## 6. Modal Border Style
| App | Border | Box shadow |
|---|---|---|
| Tinker Tickets | `3px double var(--terminal-green)` | none |
| PULSE | `3px double var(--terminal-green)` | `0 0 30px rgba(0,255,65,.3)` |
| GANDALF | `1px solid var(--green)` | `0 0 30px rgba(0,255,65,.18)` |
**Change required in GANDALF:** Upgrade to `3px double` and add PULSE-style glow:
```css
.modal {
border: 3px double var(--green);
box-shadow: 0 0 30px rgba(0, 255, 65, 0.2), 0 8px 40px rgba(0,0,0,0.8);
}
```
---
## 7. Modal Corner Characters
| App | Top corners | Notes |
|---|---|---|
| Tinker Tickets | `╔ ╗` (double-line) | Matches `3px double` border |
| PULSE | `╔ ╗` (double-line) | Matches `3px double` border |
| GANDALF | `┌ ┐` (single-line) | Doesn't match — should be `╔ ╗` for modals |
**Change required in GANDALF:**
```css
.modal::before { content: '╔'; }
.modal::after { content: '╗'; }
```
---
## 8. Toast Position & Animation
| Aspect | Tinker Tickets | PULSE | GANDALF | **Target** |
|---|---|---|---|---|
| Position | `bottom: 1rem; right: 1rem` | `top: 80px; right: 20px` | `bottom: 20px; right: 20px` | `bottom: 1rem; right: 1rem` |
| Slide direction | from bottom | from top | from right | from right (`translateX(30px)`) |
| Animation duration | `0.3s ease` | `0.3s ease-out` | `0.15s ease` | `0.2s ease-out` |
| Auto-dismiss | 3500ms | 3000ms | 3500ms | `3500ms` |
| Icon format | `>> ` prefix | `✓/✗/` prefix (inline style) | `>> ` prefix | `>> ` prefix + icon in `.lt-toast-icon` |
| Queue system | Yes (serialised) | No (stacks) | No (stacks) | Yes (serialised) |
**Change required in PULSE:** Move toast position to `bottom: 20px; right: 20px`.
Replace inline-style notification function with `.lt-toast` classes.
**Change required in GANDALF:** Already close — update animation to `slide-in-right` instead of `slide-in` (which slides from left in Gandalf's current implementation).
---
## 9. Table Cell Borders
| App | Approach |
|---|---|
| Tinker Tickets | `border: 1px solid var(--border-color)` on every `<td>` — full grid |
| PULSE | Minimal table usage; when present, `border-bottom` only |
| GANDALF | `border-collapse: collapse` + `border-bottom: 1px solid rgba(0,255,65,.08)` row-only |
Both approaches are valid for different use cases. The design system provides both:
- `.lt-table` → full-grid borders (Tinker Tickets style, simple data)
- `.lt-data-table` → row-only borders (GANDALF style, dense data)
**Action:** Migrate existing tables to the appropriate class. No visual breakage, just choose the right variant per context.
---
## 10. Form Label Colour
| App | Label colour |
|---|---|
| Tinker Tickets | `color: var(--terminal-green)` |
| PULSE | `color: var(--terminal-green)` |
| GANDALF | `color: var(--amber)` (amber labels) |
GANDALF's amber labels create a better visual hierarchy (labels stand out from field values). The unified design system adopts **amber labels** for all apps.
**Change required in Tinker Tickets and PULSE:**
```css
/* Tinker Tickets: assets/css/dashboard.css + ticket.css */
label, .filter-group h4 { color: var(--terminal-amber); text-shadow: var(--glow-amber); }
/* PULSE: index.html inline CSS */
label { color: var(--terminal-amber); }
```
---
## 11. Nav Link Active State
| Aspect | Tinker Tickets | PULSE (tabs) | GANDALF | **Target** |
|---|---|---|---|---|
| Active colour | amber | amber | amber | amber ✓ |
| Active background | `rgba(0,255,65,.08)` | `rgba(0,255,65,.2)` | `rgba(0,255,65,.07)` | `rgba(255,176,0,.15)` (amber tint) |
| Active border | green | amber | `border-color: var(--border)` (invisible) | amber |
| `[ ]` brackets | on `.btn` but not nav | on `.tab` | on `.nav-link` | on nav links |
**Change required in Tinker Tickets:** Add `[ ]` bracket decoration to nav links to match GANDALF. Currently Tinker Tickets has plain nav links without brackets.
**Change required in GANDALF:** Use amber tint background on active state instead of green tint:
```css
.nav-link.active {
background: var(--amber-dim); /* was: var(--green-dim) */
border-color: var(--amber);
}
```
---
## 12. `text-muted` Colour Value
| App | Value | Contrast on #0a0a0a |
|---|---|---|
| Tinker Tickets | `#00bb33` | ~4.8:1 |
| PULSE | `#008822` | ~2.9:1 ✗ WCAG AA fail |
| GANDALF | `#00bb33` | ~4.8:1 |
**Change required in PULSE:** Update `--text-muted` from `#008822` to `#00bb33` to pass WCAG AA contrast.
```css
/* PULSE index.html :root */
--text-muted: #00bb33; /* was: #008822 */
```
---
## 13. Boot Sequence — Presence & Format
| App | Boot sequence | App name in banner |
|---|---|---|
| Tinker Tickets | Yes — `showBootSequence()` in `DashboardView.php` | "TINKER TICKETS TERMINAL v1.0" |
| PULSE | Yes — `showBootSequence()` in `index.html` | "PULSE ORCHESTRATION TERMINAL v1.0" |
| GANDALF | **No** | — |
**Change required in GANDALF:** Add boot sequence overlay to `base.html`.
```html
<!-- Add to base.html <body> -->
<div id="lt-boot" class="lt-boot-overlay" data-app-name="GANDALF" style="display:none">
<pre id="lt-boot-text" class="lt-boot-text"></pre>
</div>
```
Plus add `lt.boot.run('GANDALF');` in `app.js` or inline at end of `base.html`.
---
## 14. Status Badge Format
| App | Format | Example |
|---|---|---|
| Tinker Tickets | `[● Open]``::before`/`::after` brackets | Brackets are pseudo-elements |
| PULSE | `[● Online]` — same pattern | Same |
| GANDALF | `.chip::before { content: '['; }` + `.chip::after { content: ']'; }` | Same pattern — different class names |
The pattern is consistent. The only issue is **class names** differ:
| Component | Tinker Tickets | PULSE | GANDALF |
|---|---|---|---|
| Full status badge | `.status-Open`, `.status-Closed` | `.status.online`, `.status.failed` | `.chip-critical`, `.chip-ok` |
| Small badge | — | `.badge` | `.badge`, `.chip` |
**Standardise to** `.lt-status-*` (full badge) + `.lt-chip-*` (compact) + `.lt-badge-*` (inline label) going forward. Existing class names can remain as app-internal aliases.
---
## 15. Scanline Effect Differences
All three apps have the same scanline `body::before` and data-stream `body::after`. Minor differences:
| Aspect | Tinker Tickets | PULSE | GANDALF |
|---|---|---|---|
| Scanline opacity | `rgba(0,0,0,0.15)` | `rgba(0,0,0,0.15)` | `rgba(0,0,0,.13)` |
| Flicker delay | `30s` | `30s` | `45s` |
| Data stream position | `bottom:10px; right:10px` | `bottom:10px; right:10px` | `bottom:10px; right:14px` |
These are minor. **Standardise to:** opacity `0.15`, flicker delay `30s`, position `bottom:10px; right:14px`.
---
## 16. Hover Transform on Cards / Items
| App | Card hover | List item hover |
|---|---|---|
| Tinker Tickets | `translateY(-2px)` + glow | `translateY(-2px)` |
| PULSE | `translateY(-2px)` | `translateX(3px)` |
| GANDALF | `border-color` change only, no transform | `border-left-width` expansion |
**Standardise to:** Cards use `translateY(-2px)`. List/row items use `border-left-width` expansion (GANDALF approach, less disorienting for dense lists).
---
## 17. CSS Architecture — Inline vs. External
| App | CSS location |
|---|---|
| Tinker Tickets | External: `assets/css/dashboard.css` + `ticket.css` |
| PULSE | **All inline** in `index.html` (`<style>` tag, ~800 lines) |
| GANDALF | External: `static/style.css` |
**Change required in PULSE:** Extract the `<style>` block from `index.html` into `public/style.css`. This enables:
- Browser caching
- Cache-busting with `?v=YYYYMMDD`
- Editor syntax highlighting
- Easier diff when updating to unified base.css
---
## 18. JavaScript Toast Implementation
| Aspect | Tinker Tickets | PULSE | GANDALF |
|---|---|---|---|
| Function name | `showToast(msg, type, duration)` | `showTerminalNotification(msg, type)` | `showToast(msg, type)` |
| Uses CSS classes | Yes (`.terminal-toast`) | No (inline styles) | Yes (`.toast`) |
| Queue system | Yes | No | No |
| Plays audio | No | Yes (Web Audio) | No |
**Standardise all three** to use `lt.toast.*` from `base.js`. The function is already a superset of all three implementations.
Required changes:
- Tinker Tickets: Replace `showToast()` in `toast.js` with `lt.toast.*` calls
- PULSE: Replace `showTerminalNotification()` in `index.html` with `lt.toast.*` calls
- GANDALF: Replace `showToast()` in `app.js` with `lt.toast.*` calls
---
## 19. CSRF Implementation
| App | Method | Token location |
|---|---|---|
| Tinker Tickets | PHP `CsrfMiddleware::getToken()``X-CSRF-Token` header | `window.CSRF_TOKEN` via inline script |
| PULSE | No CSRF tokens (session cookies + API key auth) | — |
| GANDALF | No CSRF tokens (Authelia `SameSite=Strict`) | — |
No breaking changes needed. `lt.csrf.headers()` gracefully returns `{}` when `window.CSRF_TOKEN` is not set, so PULSE and GANDALF are unaffected.
---
## 20. Responsive Design Coverage
| App | Mobile-responsive? | Notes |
|---|---|---|
| Tinker Tickets | Yes — extensive breakpoints, card view below 1400px | Most complete |
| PULSE | Partial — `auto-fit` grid, 90vw modals | No nav changes on mobile |
| GANDALF | Partial — grid wraps, no explicit breakpoints documented | Nav becomes scrollable |
**Change required in PULSE and GANDALF:** Add mobile nav handling (hamburger / collapsible), hide `.lt-nav` below 768px, ensure tables are horizontally scrollable on mobile.
---
## Priority Order for Convergence
| Priority | Item | Effort | Impact |
|---|---|---|---|
| 🔴 High | **GANDALF: CSS variable renaming** (§1) | Medium | Eliminates all naming confusion |
| 🔴 High | **PULSE: Extract inline CSS to file** (§17) | Low | Enables sharing base.css |
| 🔴 High | **All: Standardise toast to `lt.toast`** (§18) | Low | Consistent UX across apps |
| 🟡 Medium | **GANDALF: Border widths 1px→2px** (§2) | Low | Visual parity |
| 🟡 Medium | **GANDALF: Modal → `3px double`** (§6) | Low | Visual parity |
| 🟡 Medium | **PULSE: Fix `--text-muted` contrast** (§12) | Trivial | Accessibility |
| 🟡 Medium | **All: Section headers → `╠═══ X ═══╣`** (§5) | Low | Consistent branding |
| 🟡 Medium | **GANDALF: Add boot sequence** (§13) | Low | Brand consistency |
| 🟢 Low | **GANDALF: Button `[ ]` brackets + hover lift** (§3) | Low | Visual consistency |
| 🟢 Low | **TT + PULSE: Amber form labels** (§10) | Low | Better hierarchy |
| 🟢 Low | **Scanline exact values** (§15) | Trivial | Micro-consistency |
| 🟢 Low | **PULSE: Toast position bottom-right** (§8) | Trivial | UX consistency |
| 🟢 Low | **GANDALF: Glow 2-layer → 3-layer** (§4) | Trivial | Visual consistency |
---
## What Does NOT Need to Change
These differences are intentional or platform-specific and should be preserved:
- **GANDALF's compact font sizes** (`.7em` for table headers) — appropriate for dense network data
- **PULSE's tab-based navigation** — appropriate for single-page app structure
- **Tinker Tickets' full table cell borders** — appropriate for ticket data with many columns
- **PULSE's WebSocket real-time updates** — architecture difference, not aesthetic
- **GANDALF's collapse state stored in sessionStorage** vs. Tinker Tickets using CSS class toggle — both work
- **Status colour values for tickets vs. network events** — apps use different semantic colours for their domain (ticket statuses vs. UP/DOWN states)
- **Priority colour scale** — only Tinker Tickets uses P1P5; the other apps don't need it

1729
base.css Normal file

File diff suppressed because it is too large Load Diff

716
base.html Normal file
View File

@@ -0,0 +1,716 @@
<!DOCTYPE html>
<!--
LOTUSGUILD TERMINAL DESIGN SYSTEM v1.0 — base.html
Reference template showing every component and layout pattern.
This file is a STATIC DEMO. Framework-specific wiring is in:
php/ → PHP / Tinker Tickets
python/ → Flask / Jinja2 / GANDALF
node/ → Express / EJS / PULSE
To build a new app, copy one of the framework skeletons from those
sub-directories and reference base.css + base.js.
-->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MY APP — LotusGuild</title>
<meta name="description" content="LotusGuild infrastructure application">
<!-- =========================================================
Security headers are set server-side. CSP nonce is injected
by SecurityHeadersMiddleware (PHP) / helmet (Node) / Flask.
All <script> tags need: nonce="NONCE_PLACEHOLDER"
========================================================= -->
<!-- Base design system CSS -->
<link rel="stylesheet" href="/web_template/base.css">
<!--
App-specific CSS (override or extend after base.css):
<link rel="stylesheet" href="/assets/css/app.css">
-->
<link rel="icon" href="/assets/images/favicon.png" type="image/png">
</head>
<body>
<!-- ===========================================================
BOOT SEQUENCE OVERLAY
Displays once per session. Remove if not desired.
data-app-name → used in the boot text banner.
=========================================================== -->
<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>
<!-- ===========================================================
HEADER
=========================================================== -->
<header class="lt-header">
<div class="lt-header-left">
<!-- Brand -->
<div class="lt-brand">
<span class="lt-brand-title">MY APP</span>
<span class="lt-brand-subtitle">LotusGuild Infrastructure</span>
</div>
<!-- Horizontal nav links -->
<nav class="lt-nav" aria-label="Main navigation">
<a href="/" class="lt-nav-link active">Dashboard</a>
<a href="/tickets" class="lt-nav-link">Tickets</a>
<a href="/workers" class="lt-nav-link">Workers</a>
<!-- Dropdown example (admin menu) -->
<div class="lt-nav-dropdown">
<a href="#" class="lt-nav-link">Admin ▾</a>
<ul class="lt-nav-dropdown-menu">
<li><a href="/admin/templates">Templates</a></li>
<li><a href="/admin/workflow">Workflow</a></li>
<li><a href="/admin/audit-log">Audit Log</a></li>
<li><a href="/admin/api-keys">API Keys</a></li>
</ul>
</div>
</nav>
</div>
<div class="lt-header-right">
<!-- Current user -->
<span class="lt-header-user">operator</span>
<!-- Admin badge (only show for admins) -->
<span class="lt-badge-admin">admin</span>
</div>
</header>
<!-- ===========================================================
MAIN CONTENT AREA
=========================================================== -->
<main class="lt-main lt-container">
<!-- Page title bar -->
<div class="lt-page-header">
<h1 class="lt-page-title">Dashboard</h1>
<div class="lt-btn-group">
<a href="/create" class="lt-btn lt-btn-primary">New Ticket</a>
<button class="lt-btn lt-btn-sm" data-modal-open="export-modal">Export</button>
</div>
</div>
<!-- ==========================================================
STATS WIDGETS
data-filter-key / data-filter-val → wired by lt.statsFilter
========================================================== -->
<div class="lt-stats-grid">
<div class="lt-stat-card active" 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 class="lt-stat-card" data-filter-key="priority" data-filter-val="1">
<span class="lt-stat-icon">🔴</span>
<div class="lt-stat-info">
<span class="lt-stat-value">3</span>
<span class="lt-stat-label">Critical</span>
</div>
</div>
<div class="lt-stat-card" data-filter-key="assigned_to" data-filter-val="0">
<span class="lt-stat-icon">👤</span>
<div class="lt-stat-info">
<span class="lt-stat-value">11</span>
<span class="lt-stat-label">Unassigned</span>
</div>
</div>
<div class="lt-stat-card" data-filter-key="created" data-filter-val="today">
<span class="lt-stat-icon">📅</span>
<div class="lt-stat-info">
<span class="lt-stat-value">7</span>
<span class="lt-stat-label">Today</span>
</div>
</div>
</div>
<!-- ==========================================================
TAB NAVIGATION
========================================================== -->
<div class="lt-tabs">
<button class="lt-tab active" data-tab="tab-table">Table View</button>
<button class="lt-tab" data-tab="tab-kanban">Kanban</button>
<button class="lt-tab" data-tab="tab-workers">Workers</button>
</div>
<!-- ==========================================================
SIDEBAR + CONTENT LAYOUT
========================================================== -->
<div class="lt-layout">
<!-- Sidebar filter panel -->
<aside class="lt-sidebar" id="lt-sidebar">
<div class="lt-sidebar-header">
Filters
<button class="lt-sidebar-toggle" data-sidebar-toggle="lt-sidebar"
aria-label="Collapse filters"></button>
</div>
<div class="lt-sidebar-body">
<div class="lt-filter-group">
<span class="lt-filter-label">Status</span>
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" checked> Open
</label>
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox"> Pending
</label>
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox"> In Progress
</label>
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox"> Closed
</label>
</div>
<div class="lt-filter-group">
<span class="lt-filter-label">Priority</span>
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox"> P1 Critical
</label>
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox"> P2 High
</label>
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox"> P3 Medium
</label>
</div>
<div class="lt-filter-group">
<span class="lt-filter-label">Assigned To</span>
<div class="lt-form-group">
<select class="lt-select lt-btn-sm">
<option value="">All users</option>
<option>operator</option>
<option>admin</option>
</select>
</div>
</div>
<div class="lt-btn-group">
<button class="lt-btn lt-btn-sm">Apply</button>
<button class="lt-btn lt-btn-sm lt-btn-ghost">Reset</button>
</div>
</div>
</aside>
<!-- Main content -->
<div class="lt-content">
<!-- Toolbar: search + actions -->
<div class="lt-toolbar">
<div class="lt-toolbar-left">
<div class="lt-search">
<input type="search" class="lt-input lt-search-input"
placeholder="Search tickets..." aria-label="Search">
</div>
<button class="lt-btn lt-btn-sm">Advanced ▾</button>
</div>
<div class="lt-toolbar-right">
<span class="lt-text-muted lt-text-xs">42 results</span>
<button class="lt-btn lt-btn-sm lt-btn-ghost">Bulk Actions</button>
</div>
</div>
<!-- ==================================================
TAB PANEL: TABLE VIEW
================================================== -->
<div id="tab-table" class="lt-tab-panel active">
<!-- Outer ASCII frame wrapping the table -->
<div class="lt-frame">
<span class="lt-frame-bl"></span>
<span class="lt-frame-br"></span>
<div class="lt-section-header">Ticket Queue</div>
<div class="lt-table-wrap">
<table class="lt-table" id="ticket-table">
<thead>
<tr>
<th><input type="checkbox" class="lt-checkbox" aria-label="Select all"></th>
<th data-sort-key="id">ID</th>
<th data-sort-key="priority">Priority</th>
<th data-sort-key="title">Title</th>
<th data-sort-key="status">Status</th>
<th data-sort-key="assignee">Assignee</th>
<th data-sort-key="created">Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<!-- P1 Critical row -->
<tr class="lt-row-p1 lt-row-critical">
<td><input type="checkbox" class="lt-checkbox"></td>
<td><a href="/ticket/123456789">#123456789</a></td>
<td><span class="lt-p1">P1 Critical</span></td>
<td>Storage array link-down on compute-storage-01</td>
<td><span class="lt-status lt-status-open">Open</span></td>
<td class="lt-text-muted">Unassigned</td>
<td class="lt-text-xs lt-text-muted">5m ago</td>
<td>
<div class="lt-btn-group">
<a href="/ticket/123456789" class="lt-btn lt-btn-sm">View</a>
<button class="lt-btn lt-btn-sm lt-btn-danger">Close</button>
</div>
</td>
</tr>
<!-- P2 High row -->
<tr class="lt-row-p2 lt-row-warning">
<td><input type="checkbox" class="lt-checkbox"></td>
<td><a href="/ticket/987654321">#987654321</a></td>
<td><span class="lt-p2">P2 High</span></td>
<td>Switch port flapping on USW-Pro-24</td>
<td><span class="lt-status lt-status-in-progress">In Progress</span></td>
<td>operator</td>
<td class="lt-text-xs lt-text-muted">2h ago</td>
<td>
<div class="lt-btn-group">
<a href="/ticket/987654321" class="lt-btn lt-btn-sm">View</a>
</div>
</td>
</tr>
<!-- P3 Medium row -->
<tr class="lt-row-p3">
<td><input type="checkbox" class="lt-checkbox"></td>
<td><a href="/ticket/111222333">#111222333</a></td>
<td><span class="lt-p3">P3 Med</span></td>
<td>Scheduled maintenance: replace SFP+ on large1</td>
<td><span class="lt-status lt-status-pending">Pending</span></td>
<td>admin</td>
<td class="lt-text-xs lt-text-muted">1d ago</td>
<td>
<div class="lt-btn-group">
<a href="/ticket/111222333" class="lt-btn lt-btn-sm">View</a>
</div>
</td>
</tr>
<!-- P4 closed -->
<tr class="lt-row-p4">
<td><input type="checkbox" class="lt-checkbox"></td>
<td><a href="/ticket/444555666">#444555666</a></td>
<td><span class="lt-p4">P4 Low</span></td>
<td>Update SSL cert on wiki.lotusguild.org</td>
<td><span class="lt-status lt-status-closed">Closed</span></td>
<td>operator</td>
<td class="lt-text-xs lt-text-muted">3d ago</td>
<td>
<div class="lt-btn-group">
<a href="/ticket/444555666" class="lt-btn lt-btn-sm">View</a>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div><!-- /.lt-frame -->
</div><!-- /#tab-table -->
<!-- ==================================================
TAB PANEL: KANBAN
================================================== -->
<div id="tab-kanban" class="lt-tab-panel">
<div class="lt-grid-4">
<!-- Kanban column: Open -->
<div class="lt-frame">
<span class="lt-frame-bl"></span>
<span class="lt-frame-br"></span>
<div class="lt-section-header">Open</div>
<div class="lt-section-body">
<div class="lt-card lt-mb-md lt-row-p1">
<div class="lt-flex lt-flex-between lt-mb-md">
<span class="lt-p1">P1</span>
<span class="lt-dot lt-dot-up"></span>
</div>
<div class="lt-text-sm">Storage array link-down</div>
<div class="lt-text-xs lt-text-muted lt-mt-sm">5m ago · Unassigned</div>
</div>
<div class="lt-card lt-row-p3">
<div class="lt-flex lt-flex-between lt-mb-md">
<span class="lt-p3">P3</span>
<span class="lt-dot lt-dot-up"></span>
</div>
<div class="lt-text-sm">Update node_exporter on micro1</div>
<div class="lt-text-xs lt-text-muted lt-mt-sm">1d ago · operator</div>
</div>
</div>
</div>
<!-- Kanban column: Pending -->
<div class="lt-frame">
<span class="lt-frame-bl"></span>
<span class="lt-frame-br"></span>
<div class="lt-section-header">Pending</div>
<div class="lt-section-body">
<div class="lt-card lt-row-p2">
<div class="lt-flex lt-flex-between lt-mb-md">
<span class="lt-p2">P2</span>
<span class="lt-dot lt-dot-warn"></span>
</div>
<div class="lt-text-sm">Scheduled maintenance window</div>
<div class="lt-text-xs lt-text-muted lt-mt-sm">2d ago · admin</div>
</div>
</div>
</div>
<!-- Kanban column: In Progress -->
<div class="lt-frame">
<span class="lt-frame-bl"></span>
<span class="lt-frame-br"></span>
<div class="lt-section-header">In Progress</div>
<div class="lt-section-body">
<div class="lt-card lt-row-p2 lt-item-running">
<div class="lt-flex lt-flex-between lt-mb-md">
<span class="lt-p2">P2</span>
<span class="lt-dot lt-dot-warn"></span>
</div>
<div class="lt-text-sm">Switch port flapping on USW-Pro</div>
<div class="lt-text-xs lt-text-muted lt-mt-sm">2h ago · operator</div>
</div>
</div>
</div>
<!-- Kanban column: Closed -->
<div class="lt-frame">
<span class="lt-frame-bl"></span>
<span class="lt-frame-br"></span>
<div class="lt-section-header">Closed</div>
<div class="lt-section-body">
<div class="lt-card lt-row-p4" style="opacity:0.6">
<div class="lt-flex lt-flex-between lt-mb-md">
<span class="lt-p4">P4</span>
<span class="lt-dot lt-dot-idle"></span>
</div>
<div class="lt-text-sm">Update SSL cert on wiki</div>
<div class="lt-text-xs lt-text-muted lt-mt-sm">3d ago · operator</div>
</div>
</div>
</div>
</div><!-- /.lt-grid-4 -->
</div><!-- /#tab-kanban -->
<!-- ==================================================
TAB PANEL: WORKERS
================================================== -->
<div id="tab-workers" class="lt-tab-panel">
<div class="lt-grid-3">
<div class="lt-card">
<div class="lt-flex lt-flex-between lt-mb-md">
<span class="lt-text-amber lt-text-upper">pulse-worker-01</span>
<span class="lt-status lt-status-online">Online</span>
</div>
<div class="lt-data-table-wrapper">
<table class="lt-data-table">
<tr><td class="lt-text-muted">CPU</td> <td>12%</td></tr>
<tr><td class="lt-text-muted">Memory</td> <td>2.1 GB / 8 GB</td></tr>
<tr><td class="lt-text-muted">Load</td> <td>0.42 / 0.51 / 0.48</td></tr>
<tr><td class="lt-text-muted">Uptime</td> <td>14d 6h</td></tr>
<tr><td class="lt-text-muted">Tasks</td> <td>2 / 5</td></tr>
</table>
</div>
</div>
<div class="lt-card">
<div class="lt-flex lt-flex-between lt-mb-md">
<span class="lt-text-amber lt-text-upper">pulse-worker-02</span>
<span class="lt-status lt-status-offline">Offline</span>
</div>
<div class="lt-msg lt-msg-warning">Last seen 14m ago</div>
</div>
<div class="lt-card">
<div class="lt-empty">No more workers registered.</div>
</div>
</div>
</div><!-- /#tab-workers -->
</div><!-- /.lt-content -->
</div><!-- /.lt-layout -->
<!-- ==========================================================
COMPONENT SHOWCASE (remove in production)
========================================================== -->
<div class="lt-divider"></div>
<!-- Inner frame + subsection example -->
<div class="lt-frame">
<span class="lt-frame-bl"></span>
<span class="lt-frame-br"></span>
<div class="lt-section-header">Component Reference</div>
<div class="lt-frame-inner">
<div class="lt-subsection-header">Buttons</div>
<div class="lt-section-body">
<div class="lt-btn-group">
<button class="lt-btn">Default</button>
<button class="lt-btn lt-btn-primary">Primary</button>
<button class="lt-btn lt-btn-danger">Danger</button>
<button class="lt-btn lt-btn-sm">Small</button>
<button class="lt-btn lt-btn-ghost">Ghost</button>
<button class="lt-btn" disabled>Disabled</button>
</div>
</div>
<div class="lt-subsection-header">Status Badges</div>
<div class="lt-section-body lt-flex lt-flex-wrap lt-gap-md">
<span class="lt-status lt-status-open">Open</span>
<span class="lt-status lt-status-pending">Pending</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-offline">Offline</span>
<span class="lt-status lt-status-running">Running</span>
<span class="lt-status lt-status-completed">Completed</span>
<span class="lt-status lt-status-failed">Failed</span>
</div>
<div class="lt-subsection-header">Priority Badges</div>
<div class="lt-section-body lt-flex lt-flex-wrap lt-gap-md">
<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>
</div>
<div class="lt-subsection-header">Chips &amp; Badges</div>
<div class="lt-section-body lt-flex lt-flex-wrap lt-gap-sm">
<span class="lt-chip lt-chip-ok">OK</span>
<span class="lt-chip lt-chip-warn">Warning</span>
<span class="lt-chip lt-chip-critical">Critical</span>
<span class="lt-chip lt-chip-info">Info</span>
<span class="lt-badge lt-badge-green">v1.0</span>
<span class="lt-badge lt-badge-amber">Beta</span>
<span class="lt-badge lt-badge-red">Deprecated</span>
<span class="lt-badge-admin">admin</span>
</div>
<div class="lt-subsection-header">Status Dots</div>
<div class="lt-section-body lt-flex lt-gap-md">
<span class="lt-flex lt-gap-sm"><span class="lt-dot lt-dot-up"></span> Up</span>
<span class="lt-flex lt-gap-sm"><span class="lt-dot lt-dot-down"></span> Down</span>
<span class="lt-flex lt-gap-sm"><span class="lt-dot lt-dot-warn"></span> Degraded</span>
<span class="lt-flex lt-gap-sm"><span class="lt-dot lt-dot-idle"></span> Idle</span>
</div>
<div class="lt-subsection-header">Inline Messages</div>
<div class="lt-section-body">
<div class="lt-msg lt-msg-error">Database connection refused on 10.10.10.50</div>
<div class="lt-msg lt-msg-success">Ticket #123456789 updated successfully</div>
<div class="lt-msg lt-msg-warning">Rate limit: 80% consumed (40/50 req/min)</div>
<div class="lt-msg lt-msg-info">Auto-refresh active — updates every 30s</div>
</div>
<div class="lt-subsection-header">Forms</div>
<div class="lt-section-body">
<div class="lt-grid-2">
<div>
<div class="lt-form-group">
<label class="lt-label lt-label-required" for="eg-title">Ticket Title</label>
<input id="eg-title" type="text" class="lt-input" placeholder="Describe the issue">
</div>
<div class="lt-form-group">
<label class="lt-label" for="eg-priority">Priority</label>
<select id="eg-priority" class="lt-select">
<option>P1 — Critical</option>
<option>P2 — High</option>
<option selected>P3 — Medium</option>
<option>P4 — Low</option>
<option>P5 — Minimal</option>
</select>
</div>
<div class="lt-form-group">
<label class="lt-label" for="eg-desc">Description</label>
<textarea id="eg-desc" class="lt-textarea" placeholder="Markdown supported..."></textarea>
<span class="lt-form-hint">Markdown supported. Use #123456789 to link tickets.</span>
</div>
<div class="lt-form-group">
<label class="lt-label">
<input type="checkbox" class="lt-checkbox"> Confidential ticket
</label>
</div>
<div class="lt-btn-group">
<button class="lt-btn lt-btn-primary">Submit</button>
<button class="lt-btn lt-btn-ghost">Cancel</button>
</div>
</div>
<div>
<div class="lt-search lt-form-group">
<label class="lt-label" for="eg-search">Search</label>
<input id="eg-search" type="search" class="lt-input lt-search-input"
placeholder="Ctrl+K to focus">
</div>
<div class="lt-form-group">
<label class="lt-label">Loading state (skeleton)</label>
<div class="lt-skeleton lt-p-md" style="height:40px"></div>
</div>
<div class="lt-form-group">
<label class="lt-label">Empty state</label>
<div class="lt-empty" style="padding:1rem">No results found</div>
</div>
<div class="lt-form-group">
<label class="lt-label">Loading indicator</label>
<div class="lt-loading" style="padding:1rem"></div>
</div>
</div>
</div>
</div>
<div class="lt-subsection-header">Log / Timeline Entries</div>
<div class="lt-section-body">
<div class="lt-log-entry success">
<div class="lt-log-ts">2026-03-14 09:12:33 EDT</div>
<strong>Status changed:</strong> Open → In Progress by operator
</div>
<div class="lt-log-entry warning">
<div class="lt-log-ts">2026-03-14 09:10:11 EDT</div>
<strong>Priority escalated:</strong> P3 → P1 by GANDALF auto-alert
</div>
<div class="lt-log-entry error">
<div class="lt-log-ts">2026-03-14 09:09:55 EDT</div>
<strong>Alert triggered:</strong> NIC link-down on large1:enp35s0
<div class="lt-log-output">node_exporter metric: node_network_up{interface="enp35s0"} = 0</div>
</div>
</div>
</div><!-- /.lt-frame-inner -->
</div><!-- /.lt-frame (component showcase) -->
</main><!-- /.lt-main -->
<!-- ===========================================================
TOAST DEMO BUTTONS (remove in production)
=========================================================== -->
<div style="position:fixed;bottom:1rem;left:1rem;display:flex;flex-direction:column;gap:0.5rem;z-index:900">
<button class="lt-btn lt-btn-sm" onclick="lt.toast.success('Ticket saved successfully')">✓ Toast</button>
<button class="lt-btn lt-btn-sm lt-btn-danger" onclick="lt.toast.error('Network error — retry in 5s', 5000)">✗ Error</button>
<button class="lt-btn lt-btn-sm" onclick="lt.toast.warning('Rate limit 80% used')">! Warn</button>
<button class="lt-btn lt-btn-sm lt-btn-ghost" onclick="lt.toast.info('Auto-refresh triggered')">i Info</button>
</div>
<!-- ===========================================================
MODAL EXAMPLE: Export
=========================================================== -->
<div id="export-modal" class="lt-modal-overlay" aria-hidden="true">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title">Export Tickets</span>
<button class="lt-modal-close" data-modal-close aria-label="Close"></button>
</div>
<div class="lt-modal-body">
<div class="lt-form-group">
<label class="lt-label" for="export-fmt">Format</label>
<select id="export-fmt" class="lt-select">
<option>CSV</option>
<option>JSON</option>
</select>
</div>
<div class="lt-form-group">
<label class="lt-label">
<input type="checkbox" class="lt-checkbox" checked> Selected tickets only
</label>
</div>
<div class="lt-msg lt-msg-info">Exports include all visible columns.</div>
</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" onclick="lt.toast.success('Export started'); lt.modal.close('export-modal')">Export</button>
</div>
</div>
</div>
<!-- ===========================================================
MODAL EXAMPLE: Keyboard shortcuts help
=========================================================== -->
<div id="lt-keys-help" class="lt-modal-overlay" aria-hidden="true">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title">Keyboard Shortcuts</span>
<button class="lt-modal-close" data-modal-close aria-label="Close"></button>
</div>
<div class="lt-modal-body">
<table class="lt-data-table" style="width:100%">
<thead>
<tr><th>Shortcut</th><th>Action</th></tr>
</thead>
<tbody>
<tr><td>Ctrl / ⌘ + K</td><td>Focus search box</td></tr>
<tr><td>Ctrl / ⌘ + E</td><td>Toggle edit mode (ticket page)</td></tr>
<tr><td>Ctrl / ⌘ + S</td><td>Save changes (ticket page)</td></tr>
<tr><td>j / ↓</td><td>Select next row</td></tr>
<tr><td>k / ↑</td><td>Select previous row</td></tr>
<tr><td>Enter</td><td>Open selected ticket</td></tr>
<tr><td>n</td><td>New ticket</td></tr>
<tr><td>?</td><td>Show this help</td></tr>
<tr><td>ESC</td><td>Close modal / cancel</td></tr>
</tbody>
</table>
</div>
<div class="lt-modal-footer">
<button class="lt-btn" data-modal-close>Close</button>
</div>
</div>
</div>
<!-- ===========================================================
SCRIPTS
=========================================================== -->
<!--
PHP apps inject CSRF token + config here (with CSP nonce):
<script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
window.APP_TIMEZONE = '<?php echo $config['TIMEZONE']; ?>';
</script>
Node/Express (EJS):
<script nonce="<%= nonce %>">
window.CSRF_TOKEN = '<%= csrfToken %>';
</script>
Flask/Jinja2:
<script nonce="{{ nonce }}">
window.CSRF_TOKEN = '{{ csrf_token() }}';
</script>
-->
<!-- Base design system JS -->
<script src="/web_template/base.js"></script>
<!--
App-specific JS:
<script nonce="NONCE" src="/assets/js/app.js?v=20260314"></script>
Wire up sortable table after load:
<script nonce="NONCE">
lt.sortTable.init('ticket-table');
lt.tableNav.init('ticket-table');
lt.keys.on('n', () => window.location.href = '/create');
</script>
-->
</body>
</html>

794
base.js Normal file
View File

@@ -0,0 +1,794 @@
/**
* LOTUSGUILD TERMINAL DESIGN SYSTEM v1.0 — base.js
* Core JavaScript utilities shared across all LotusGuild applications
*
* Apps: Tinker Tickets (PHP), PULSE (Node.js), GANDALF (Flask)
* Namespace: window.lt
*
* CONTENTS
* 1. HTML Escape
* 2. Toast Notifications
* 3. Terminal Audio (beep)
* 4. Modal Management
* 5. Tab Management
* 6. Boot Sequence Animation
* 7. Keyboard Shortcuts
* 8. Sidebar Collapse
* 9. CSRF Token Helpers
* 10. Fetch Helpers (JSON API wrapper)
* 11. Time Formatting
* 12. Bytes Formatting
* 13. Table Keyboard Navigation
* 14. Sortable Table Headers
* 15. Stats Widget Filtering
* 16. Auto-refresh Manager
* 17. Initialisation
*/
(function (global) {
'use strict';
/* ----------------------------------------------------------------
1. HTML ESCAPE
---------------------------------------------------------------- */
/**
* Escape a value for safe insertion into innerHTML.
* Always prefer textContent/innerText when possible, but use this
* when you must build HTML strings (e.g. template literals for lists).
*
* @param {*} str
* @returns {string}
*/
function escHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/* ----------------------------------------------------------------
2. TOAST NOTIFICATIONS
----------------------------------------------------------------
Usage:
lt.toast.success('Ticket saved');
lt.toast.error('Network error', 5000);
lt.toast.warning('Rate limit approaching');
lt.toast.info('Workflow started');
---------------------------------------------------------------- */
const _toastQueue = [];
let _toastActive = false;
/**
* @param {string} message
* @param {'success'|'error'|'warning'|'info'} type
* @param {number} [duration=3500] ms before auto-dismiss
*/
function showToast(message, type, duration) {
type = type || 'info';
duration = duration || 3500;
if (_toastActive) {
_toastQueue.push({ message, type, duration });
return;
}
_displayToast(message, type, duration);
}
function _displayToast(message, type, duration) {
_toastActive = true;
let container = document.querySelector('.lt-toast-container');
if (!container) {
container = document.createElement('div');
container.className = 'lt-toast-container';
document.body.appendChild(container);
}
const icons = { success: '✓', error: '✗', warning: '!', info: 'i' };
const toast = document.createElement('div');
toast.className = 'lt-toast lt-toast-' + type;
toast.setAttribute('role', 'alert');
toast.setAttribute('aria-live', 'polite');
const iconEl = document.createElement('span');
iconEl.className = 'lt-toast-icon';
iconEl.textContent = '[' + (icons[type] || 'i') + ']';
const msgEl = document.createElement('span');
msgEl.className = 'lt-toast-msg';
msgEl.textContent = message;
const closeEl = document.createElement('button');
closeEl.className = 'lt-toast-close';
closeEl.textContent = '✕';
closeEl.setAttribute('aria-label', 'Dismiss');
closeEl.addEventListener('click', () => _dismissToast(toast));
toast.appendChild(iconEl);
toast.appendChild(msgEl);
toast.appendChild(closeEl);
container.appendChild(toast);
/* Auto-dismiss */
const timer = setTimeout(() => _dismissToast(toast), duration);
toast._lt_timer = timer;
/* Optional audio feedback */
_beep(type);
}
function _dismissToast(toast) {
if (!toast || !toast.parentNode) return;
clearTimeout(toast._lt_timer);
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s ease';
setTimeout(() => {
if (toast.parentNode) toast.parentNode.removeChild(toast);
_toastActive = false;
if (_toastQueue.length) {
const next = _toastQueue.shift();
_displayToast(next.message, next.type, next.duration);
}
}, 320);
}
const toast = {
success: (msg, dur) => showToast(msg, 'success', dur),
error: (msg, dur) => showToast(msg, 'error', dur),
warning: (msg, dur) => showToast(msg, 'warning', dur),
info: (msg, dur) => showToast(msg, 'info', dur),
};
/* ----------------------------------------------------------------
3. TERMINAL AUDIO
----------------------------------------------------------------
Usage: lt.beep('success' | 'error' | 'info')
Silent-fails if Web Audio API is unavailable.
---------------------------------------------------------------- */
function _beep(type) {
try {
const ctx = new (global.AudioContext || global.webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = type === 'success' ? 880
: type === 'error' ? 220
: 440;
osc.type = 'sine';
gain.gain.setValueAtTime(0.08, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.12);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.12);
} catch (_) { /* silently fail */ }
}
/* ----------------------------------------------------------------
4. MODAL MANAGEMENT
----------------------------------------------------------------
Usage:
lt.modal.open('my-modal-id');
lt.modal.close('my-modal-id');
lt.modal.closeAll();
HTML contract:
<div id="my-modal-id" class="lt-modal-overlay">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title">Title</span>
<button class="lt-modal-close" data-modal-close>✕</button>
</div>
<div class="lt-modal-body">…</div>
<div class="lt-modal-footer">…</div>
</div>
</div>
---------------------------------------------------------------- */
function openModal(id) {
const el = typeof id === 'string' ? document.getElementById(id) : id;
if (!el) return;
el.classList.add('show');
el.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
/* Focus first focusable element */
const first = el.querySelector('button, input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (first) setTimeout(() => first.focus(), 50);
}
function closeModal(id) {
const el = typeof id === 'string' ? document.getElementById(id) : id;
if (!el) return;
el.classList.remove('show');
el.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
}
function closeAllModals() {
document.querySelectorAll('.lt-modal-overlay.show').forEach(closeModal);
}
/* Delegated close handlers */
document.addEventListener('click', function (e) {
/* Click on overlay backdrop (outside .lt-modal) */
if (e.target.classList.contains('lt-modal-overlay')) {
closeModal(e.target);
return;
}
/* [data-modal-close] button */
const closeBtn = e.target.closest('[data-modal-close]');
if (closeBtn) {
const overlay = closeBtn.closest('.lt-modal-overlay');
if (overlay) closeModal(overlay);
}
/* [data-modal-open="id"] trigger */
const openBtn = e.target.closest('[data-modal-open]');
if (openBtn) openModal(openBtn.dataset.modalOpen);
});
const modal = { open: openModal, close: closeModal, closeAll: closeAllModals };
/* ----------------------------------------------------------------
5. TAB MANAGEMENT
----------------------------------------------------------------
Usage:
lt.tabs.init(); // auto-wires all .lt-tab elements
lt.tabs.switch('tab-panel-id');
HTML contract:
<div class="lt-tabs">
<button class="lt-tab active" data-tab="panel-one">One</button>
<button class="lt-tab" data-tab="panel-two">Two</button>
</div>
<div id="panel-one" class="lt-tab-panel active">…</div>
<div id="panel-two" class="lt-tab-panel">…</div>
Persistence: localStorage key 'lt_activeTab_<page>'
---------------------------------------------------------------- */
function switchTab(panelId) {
document.querySelectorAll('.lt-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.lt-tab-panel').forEach(p => p.classList.remove('active'));
const btn = document.querySelector('.lt-tab[data-tab="' + panelId + '"]');
const panel = document.getElementById(panelId);
if (btn) btn.classList.add('active');
if (panel) panel.classList.add('active');
try { localStorage.setItem('lt_activeTab_' + location.pathname, panelId); } catch (_) {}
}
function initTabs() {
/* Restore from localStorage */
try {
const saved = localStorage.getItem('lt_activeTab_' + location.pathname);
if (saved && document.getElementById(saved)) { switchTab(saved); return; }
} catch (_) {}
/* Wire click handlers */
document.querySelectorAll('.lt-tab[data-tab]').forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
});
}
const tabs = { init: initTabs, switch: switchTab };
/* ----------------------------------------------------------------
6. BOOT SEQUENCE ANIMATION
----------------------------------------------------------------
Usage:
lt.boot.run('APP NAME'); // shows once per session
lt.boot.run('APP NAME', true); // force show even if already seen
HTML contract (add to <body>, hidden by default):
<div id="lt-boot" class="lt-boot-overlay" style="display:none">
<pre id="lt-boot-text" class="lt-boot-text"></pre>
</div>
---------------------------------------------------------------- */
function runBoot(appName, force) {
const storageKey = 'lt_booted_' + (appName || 'app');
if (!force && sessionStorage.getItem(storageKey)) return;
const overlay = document.getElementById('lt-boot');
const pre = document.getElementById('lt-boot-text');
if (!overlay || !pre) return;
overlay.style.display = 'flex';
overlay.style.opacity = '1';
const name = (appName || 'TERMINAL').toUpperCase();
const bar = '═'.repeat(Math.max(0, 41 - name.length));
const pad = ' '.repeat(Math.max(0, Math.floor((39 - name.length) / 2)));
const messages = [
'╔═══════════════════════════════════════════╗',
'║' + pad + name + ' v1.0' + pad + '║',
'║ BOOTING SYSTEM... ║',
'╚═══════════════════════════════════════════╝',
'',
'[ OK ] Checking kernel modules...',
'[ OK ] Mounting filesystem...',
'[ OK ] Initializing database connection...',
'[ OK ] Loading user session...',
'[ OK ] Applying security headers...',
'[ OK ] Rendering terminal interface...',
'',
'> SYSTEM READY ✓',
'',
];
let i = 0;
pre.textContent = '';
const interval = setInterval(() => {
if (i < messages.length) {
pre.textContent += messages[i] + '\n';
i++;
} else {
clearInterval(interval);
setTimeout(() => {
overlay.classList.add('fade-out');
setTimeout(() => {
overlay.style.display = 'none';
overlay.classList.remove('fade-out');
}, 520);
}, 400);
sessionStorage.setItem(storageKey, '1');
}
}, 80);
}
const boot = { run: runBoot };
/* ----------------------------------------------------------------
7. KEYBOARD SHORTCUTS
----------------------------------------------------------------
Register handlers:
lt.keys.on('ctrl+k', () => searchBox.focus());
lt.keys.on('?', showHelpModal);
lt.keys.on('Escape', lt.modal.closeAll);
Built-in defaults (activate with lt.keys.initDefaults()):
ESC → close all modals
? → show #lt-keys-help modal if present
Ctrl/⌘+K → focus .lt-search-input
---------------------------------------------------------------- */
const _keyHandlers = {};
function normalizeKey(combo) {
return combo
.replace(/ctrl\+/i, 'ctrl+')
.replace(/cmd\+/i, 'ctrl+') /* treat Cmd as Ctrl */
.replace(/meta\+/i, 'ctrl+')
.toLowerCase();
}
function registerKey(combo, handler) {
_keyHandlers[normalizeKey(combo)] = handler;
}
function unregisterKey(combo) {
delete _keyHandlers[normalizeKey(combo)];
}
document.addEventListener('keydown', function (e) {
const inInput = ['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)
|| e.target.isContentEditable;
/* Build the combo string */
let combo = '';
if (e.ctrlKey || e.metaKey) combo += 'ctrl+';
if (e.altKey) combo += 'alt+';
if (e.shiftKey) combo += 'shift+';
combo += e.key.toLowerCase();
/* Always fire ESC, Ctrl combos regardless of input focus */
const alwaysFire = e.key === 'Escape' || e.ctrlKey || e.metaKey;
if (inInput && !alwaysFire) return;
const handler = _keyHandlers[combo];
if (handler) {
e.preventDefault();
handler(e);
}
});
function initDefaultKeys() {
registerKey('Escape', closeAllModals);
registerKey('?', () => {
const helpModal = document.getElementById('lt-keys-help');
if (helpModal) openModal(helpModal);
});
registerKey('ctrl+k', () => {
const search = document.querySelector('.lt-search-input');
if (search) { search.focus(); search.select(); }
});
}
const keys = {
on: registerKey,
off: unregisterKey,
initDefaults: initDefaultKeys,
};
/* ----------------------------------------------------------------
8. SIDEBAR COLLAPSE
----------------------------------------------------------------
Usage: lt.sidebar.init();
HTML contract:
<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>
</aside>
---------------------------------------------------------------- */
function initSidebar() {
document.querySelectorAll('[data-sidebar-toggle]').forEach(btn => {
const sidebar = document.getElementById(btn.dataset.sidebarToggle);
if (!sidebar) return;
/* Restore state */
const collapsed = sessionStorage.getItem('lt_sidebar_' + btn.dataset.sidebarToggle) === '1';
if (collapsed) {
sidebar.classList.add('collapsed');
btn.textContent = '▶';
}
btn.addEventListener('click', () => {
sidebar.classList.toggle('collapsed');
const isCollapsed = sidebar.classList.contains('collapsed');
btn.textContent = isCollapsed ? '▶' : '◀';
try { sessionStorage.setItem('lt_sidebar_' + btn.dataset.sidebarToggle, isCollapsed ? '1' : '0'); } catch (_) {}
});
});
}
const sidebar = { init: initSidebar };
/* ----------------------------------------------------------------
9. CSRF TOKEN HELPERS
----------------------------------------------------------------
PHP apps: window.CSRF_TOKEN is set by the view via:
<script nonce="...">window.CSRF_TOKEN = '<?= CsrfMiddleware::getToken() ?>';</script>
Node apps: set via: window.CSRF_TOKEN = '<%= csrfToken %>';
Flask: use Flask-WTF meta tag or inject via template.
Usage:
const headers = lt.csrf.headers();
fetch('/api/foo', { method: 'POST', headers: lt.csrf.headers(), body: … });
---------------------------------------------------------------- */
function csrfHeaders() {
const token = global.CSRF_TOKEN || '';
return token ? { 'X-CSRF-Token': token } : {};
}
const csrf = { headers: csrfHeaders };
/* ----------------------------------------------------------------
10. FETCH HELPERS
----------------------------------------------------------------
Usage:
const data = await lt.api.get('/api/tickets');
const res = await lt.api.post('/api/update_ticket.php', { id: 1, status: 'Closed' });
const res = await lt.api.delete('/api/ticket_dependencies.php', { id: 5 });
All methods:
- Automatically set Content-Type: application/json
- Attach CSRF token header
- Parse JSON response
- On non-2xx: throw an Error with the server's error message
---------------------------------------------------------------- */
async function apiFetch(method, url, body) {
const opts = {
method,
headers: Object.assign(
{ 'Content-Type': 'application/json' },
csrfHeaders()
),
};
if (body !== undefined) opts.body = JSON.stringify(body);
let resp;
try {
resp = await fetch(url, opts);
} catch (networkErr) {
throw new Error('Network error: ' + networkErr.message);
}
let data;
try {
data = await resp.json();
} catch (_) {
data = { success: resp.ok };
}
if (!resp.ok) {
throw new Error(data.error || data.message || 'HTTP ' + resp.status);
}
return data;
}
const api = {
get: (url) => apiFetch('GET', url),
post: (url, body) => apiFetch('POST', url, body),
put: (url, body) => apiFetch('PUT', url, body),
patch: (url, body) => apiFetch('PATCH', url, body),
delete: (url, body) => apiFetch('DELETE', url, body),
};
/* ----------------------------------------------------------------
11. TIME FORMATTING
---------------------------------------------------------------- */
/**
* Returns a human-readable relative time string.
* @param {string|number|Date} value ISO string, Unix ms, or Date
* @returns {string} e.g. "5m ago", "2h ago", "3d ago"
*/
function timeAgo(value) {
const date = value instanceof Date ? value : new Date(value);
if (isNaN(date)) return '—';
const diff = Math.floor((Date.now() - date.getTime()) / 1000);
if (diff < 60) return diff + 's ago';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
return Math.floor(diff / 86400) + 'd ago';
}
/**
* Format seconds → "1h 23m 45s" style.
* @param {number} secs
* @returns {string}
*/
function formatUptime(secs) {
secs = Math.floor(secs);
const d = Math.floor(secs / 86400);
const h = Math.floor((secs % 86400) / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = secs % 60;
const parts = [];
if (d) parts.push(d + 'd');
if (h) parts.push(h + 'h');
if (m) parts.push(m + 'm');
if (!d) parts.push(s + 's');
return parts.join(' ') || '0s';
}
/**
* Format an ISO datetime string for display.
* Uses the timezone configured in window.APP_TIMEZONE (PHP apps)
* or falls back to the browser locale.
*/
function formatDate(value) {
const date = value instanceof Date ? value : new Date(value);
if (isNaN(date)) return '—';
const tz = global.APP_TIMEZONE || undefined;
try {
return date.toLocaleString(undefined, {
timeZone: tz,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
});
} catch (_) {
return date.toLocaleString();
}
}
const time = { ago: timeAgo, uptime: formatUptime, format: formatDate };
/* ----------------------------------------------------------------
12. BYTES FORMATTING
---------------------------------------------------------------- */
/**
* @param {number} bytes
* @returns {string} e.g. "1.23 GB"
*/
function formatBytes(bytes) {
if (bytes === null || bytes === undefined) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; }
return bytes.toFixed(i === 0 ? 0 : 2) + ' ' + units[i];
}
/* ----------------------------------------------------------------
13. TABLE KEYBOARD NAVIGATION (vim-style j/k)
----------------------------------------------------------------
Usage: lt.tableNav.init('my-table-id');
Keys registered:
j or ArrowDown → move selection down
k or ArrowUp → move selection up
Enter → follow first <a> in selected row
---------------------------------------------------------------- */
function initTableNav(tableId) {
const table = tableId ? document.getElementById(tableId) : document.querySelector('.lt-table');
if (!table) return;
function rows() { return Array.from(table.querySelectorAll('tbody tr')); }
function selected() { return table.querySelector('tbody tr.lt-row-selected'); }
function move(dir) {
const all = rows();
if (!all.length) return;
const cur = selected();
const idx = cur ? all.indexOf(cur) : -1;
const next = dir === 'down'
? all[idx < all.length - 1 ? idx + 1 : 0]
: all[idx > 0 ? idx - 1 : all.length - 1];
if (cur) cur.classList.remove('lt-row-selected');
next.classList.add('lt-row-selected');
next.scrollIntoView({ block: 'nearest' });
}
keys.on('j', () => move('down'));
keys.on('ArrowDown', () => move('down'));
keys.on('k', () => move('up'));
keys.on('ArrowUp', () => move('up'));
keys.on('Enter', () => {
const row = selected();
if (!row) return;
const link = row.querySelector('a[href]');
if (link) global.location.href = link.href;
});
}
const tableNav = { init: initTableNav };
/* ----------------------------------------------------------------
14. SORTABLE TABLE HEADERS
----------------------------------------------------------------
Usage: lt.sortTable.init('my-table-id');
Markup: add data-sort-key="field" to <th> elements.
Sorts rows client-side by the text content of the matching column.
---------------------------------------------------------------- */
function initSortTable(tableId) {
const table = document.getElementById(tableId);
if (!table) return;
const ths = Array.from(table.querySelectorAll('th[data-sort-key]'));
ths.forEach((th, colIdx) => {
th.style.cursor = 'pointer';
let dir = 'asc';
th.addEventListener('click', () => {
/* Reset all headers */
ths.forEach(h => h.removeAttribute('data-sort'));
th.setAttribute('data-sort', dir);
const tbody = table.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort((a, b) => {
const aText = (a.cells[colIdx] || {}).textContent || '';
const bText = (b.cells[colIdx] || {}).textContent || '';
const n = !isNaN(parseFloat(aText)) && !isNaN(parseFloat(bText));
const cmp = n
? parseFloat(aText) - parseFloat(bText)
: aText.localeCompare(bText);
return dir === 'asc' ? cmp : -cmp;
});
rows.forEach(r => tbody.appendChild(r));
dir = dir === 'asc' ? 'desc' : 'asc';
});
});
}
const sortTable = { init: initSortTable };
/* ----------------------------------------------------------------
15. STATS WIDGET FILTERING
----------------------------------------------------------------
Usage: lt.statsFilter.init();
HTML contract:
<div class="lt-stat-card" data-filter-key="status" data-filter-val="Open">…</div>
<!-- clicking the card adds ?filter=status:Open to the URL and
calls the optional window.lt_onStatFilter(key, val) hook -->
---------------------------------------------------------------- */
function initStatsFilter() {
document.querySelectorAll('.lt-stat-card[data-filter-key]').forEach(card => {
card.addEventListener('click', () => {
const key = card.dataset.filterKey;
const val = card.dataset.filterVal;
/* Toggle active state */
const wasActive = card.classList.contains('active');
document.querySelectorAll('.lt-stat-card').forEach(c => c.classList.remove('active'));
if (!wasActive) card.classList.add('active');
/* Call app-specific filter hook if defined */
if (typeof global.lt_onStatFilter === 'function') {
global.lt_onStatFilter(wasActive ? null : key, wasActive ? null : val);
}
});
});
}
const statsFilter = { init: initStatsFilter };
/* ----------------------------------------------------------------
16. AUTO-REFRESH MANAGER
----------------------------------------------------------------
Usage:
lt.autoRefresh.start(refreshFn, 30000); // every 30 s
lt.autoRefresh.stop();
lt.autoRefresh.now(); // trigger immediately + restart timer
---------------------------------------------------------------- */
let _arTimer = null;
let _arFn = null;
let _arInterval = 30000;
function arStart(fn, intervalMs) {
arStop();
_arFn = fn;
_arInterval = intervalMs || 30000;
_arTimer = setInterval(_arFn, _arInterval);
}
function arStop() {
if (_arTimer) { clearInterval(_arTimer); _arTimer = null; }
}
function arNow() {
arStop();
if (_arFn) {
_arFn();
_arTimer = setInterval(_arFn, _arInterval);
}
}
const autoRefresh = { start: arStart, stop: arStop, now: arNow };
/* ----------------------------------------------------------------
17. INITIALISATION
----------------------------------------------------------------
Called automatically on DOMContentLoaded.
Each sub-system can also be initialised manually after the DOM
has been updated with AJAX content.
---------------------------------------------------------------- */
function init() {
initTabs();
initSidebar();
initDefaultKeys();
initStatsFilter();
/* Boot sequence: runs if #lt-boot element is present */
const bootEl = document.getElementById('lt-boot');
if (bootEl) {
const appName = bootEl.dataset.appName || document.title;
runBoot(appName);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
/* ----------------------------------------------------------------
Public API
---------------------------------------------------------------- */
global.lt = {
escHtml,
toast,
beep: _beep,
modal,
tabs,
boot,
keys,
sidebar,
csrf,
api,
time,
bytes: { format: formatBytes },
tableNav,
sortTable,
statsFilter,
autoRefresh,
};
}(window));

243
node/middleware.js Normal file
View File

@@ -0,0 +1,243 @@
/**
* LOTUSGUILD TERMINAL DESIGN SYSTEM — Node.js / Express Middleware
*
* Provides:
* - requireAuth Authelia SSO header validation
* - requireAdmin Admin group enforcement
* - csrfMiddleware CSRF token generation + validation
* - cspNonce CSP nonce injection (for use with helmet)
* - injectLocals Template locals (user, nonce, config)
*
* Usage in server.js / app.js:
*
* const { requireAuth, requireAdmin, cspNonce, injectLocals } = require('./middleware');
*
* // Apply globally
* app.use(cspNonce);
* app.use(requireAuth);
* app.use(injectLocals);
*
* // Apply per-route
* app.get('/admin', requireAdmin, (req, res) => res.render('admin'));
*/
'use strict';
const crypto = require('crypto');
/* ----------------------------------------------------------------
Auth — Authelia SSO header parsing
---------------------------------------------------------------- */
/**
* Parse Authelia remote headers from the request.
* @param {import('express').Request} req
* @returns {{ username, name, email, groups, isAdmin }}
*/
function parseUser(req) {
const groupsRaw = req.headers['remote-groups'] || '';
const groups = groupsRaw.split(',').map(s => s.trim()).filter(Boolean);
return {
username: req.headers['remote-user'] || '',
name: req.headers['remote-name'] || '',
email: req.headers['remote-email'] || '',
groups,
isAdmin: groups.includes('admin'),
};
}
/**
* Middleware: require authenticated Authelia user.
* Allowed groups are read from process.env.ALLOWED_GROUPS (comma-separated),
* defaulting to 'admin'.
*/
function requireAuth(req, res, next) {
const user = parseUser(req);
if (!user.username) {
return res.status(401).json({ error: 'Not authenticated — access via Authelia SSO' });
}
const allowed = (process.env.ALLOWED_GROUPS || 'admin').split(',').map(s => s.trim());
if (!user.groups.some(g => allowed.includes(g))) {
return res.status(403).json({
error: `Access denied — ${user.username} is not in an allowed group (${allowed.join(', ')})`
});
}
req.user = user;
next();
}
/**
* Middleware: require 'admin' group specifically.
* Must be used after requireAuth (or inline it calls parseUser again — cheap).
*/
function requireAdmin(req, res, next) {
const user = req.user || parseUser(req);
if (!user.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
req.user = user;
next();
}
/* ----------------------------------------------------------------
CSRF — double-submit cookie pattern
---------------------------------------------------------------- */
/**
* Middleware: generate and validate CSRF tokens.
*
* GET/HEAD/OPTIONS → generate a token, store in session, expose on res.locals.
* POST/PUT/PATCH/DELETE → validate X-CSRF-Token header against session token.
*
* Requires express-session to be configured before this middleware.
*/
function csrfMiddleware(req, res, next) {
const safeMethods = ['GET', 'HEAD', 'OPTIONS'];
if (safeMethods.includes(req.method)) {
// Generate token if not already set
if (!req.session.csrfToken) {
req.session.csrfToken = crypto.randomBytes(32).toString('hex');
}
res.locals.csrfToken = req.session.csrfToken;
return next();
}
// Validate on mutating methods
const token = req.headers['x-csrf-token'] || req.body?.csrf_token || '';
const expected = req.session.csrfToken || '';
// Constant-time comparison to prevent timing attacks
if (!expected || !timingSafeEqual(token, expected)) {
return res.status(403).json({ error: 'CSRF token invalid or missing' });
}
res.locals.csrfToken = expected;
next();
}
function timingSafeEqual(a, b) {
if (typeof a !== 'string' || typeof b !== 'string') return false;
if (a.length !== b.length) return false;
try {
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
} catch (_) {
return false;
}
}
/* ----------------------------------------------------------------
CSP Nonce — generate per-request nonce for Content-Security-Policy
---------------------------------------------------------------- */
/**
* Middleware: generate a CSP nonce and attach to res.locals.
* Use with helmet's contentSecurityPolicy:
*
* app.use(helmet({
* contentSecurityPolicy: {
* directives: {
* scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
* }
* }
* }));
*
* // In EJS templates: <script nonce="<%= nonce %>">
*/
function cspNonce(req, res, next) {
res.locals.nonce = crypto.randomBytes(16).toString('base64');
next();
}
/* ----------------------------------------------------------------
Template locals injector
---------------------------------------------------------------- */
/**
* Middleware: inject common locals into every rendered template.
* Must be applied after requireAuth (needs req.user).
*
* Injects:
* user → current user object
* nonce → CSP nonce (if cspNonce ran first)
* csrfToken → CSRF token (if csrfMiddleware ran first)
* appName → process.env.APP_NAME or 'PULSE'
* appSubtitle → process.env.APP_SUBTITLE or 'LotusGuild Infrastructure'
*/
function injectLocals(req, res, next) {
res.locals.user = req.user || { username: '', name: '', groups: [], isAdmin: false };
res.locals.appName = process.env.APP_NAME || 'PULSE';
res.locals.appSubtitle = process.env.APP_SUBTITLE || 'LotusGuild Infrastructure';
next();
}
/* ----------------------------------------------------------------
Rate Limiting helper (simple in-memory, use express-rate-limit
for production)
---------------------------------------------------------------- */
/**
* Factory: create a simple per-IP rate limiter.
* For production use express-rate-limit with a Redis store instead.
*
* @param {{ windowMs: number, max: number, message: string }} opts
*/
function createRateLimit(opts) {
const { windowMs = 60_000, max = 60, message = 'Rate limit exceeded' } = opts || {};
const hits = new Map();
return function rateLimit(req, res, next) {
const key = req.ip || 'unknown';
const now = Date.now();
const entry = hits.get(key) || { count: 0, reset: now + windowMs };
if (now > entry.reset) {
entry.count = 0;
entry.reset = now + windowMs;
}
entry.count++;
hits.set(key, entry);
if (entry.count > max) {
return res.status(429).json({ error: message });
}
next();
};
}
/* ----------------------------------------------------------------
API Key auth (for worker/external API access)
---------------------------------------------------------------- */
/**
* Middleware: validate Bearer token against WORKER_API_KEY env var.
* Used on WebSocket upgrade and worker API endpoints.
*/
function requireApiKey(req, res, next) {
const auth = req.headers.authorization || '';
const [, token] = auth.split(' ');
const expected = process.env.WORKER_API_KEY || '';
if (!expected) {
console.warn('[WARN] WORKER_API_KEY not set — API key auth disabled');
return next();
}
if (!token || !timingSafeEqual(token, expected)) {
return res.status(401).json({ error: 'Invalid API key' });
}
next();
}
module.exports = {
parseUser,
requireAuth,
requireAdmin,
csrfMiddleware,
cspNonce,
injectLocals,
createRateLimit,
requireApiKey,
};

154
php/layout.php Normal file
View File

@@ -0,0 +1,154 @@
<?php
/**
* LOTUSGUILD TERMINAL DESIGN SYSTEM — PHP Base Layout
* Copy this into your PHP app as the base view template.
*
* Requires:
* - middleware/SecurityHeadersMiddleware.php (provides $nonce)
* - middleware/CsrfMiddleware.php (provides CSRF token)
* - middleware/AuthMiddleware.php (provides $currentUser)
* - config/config.php (provides $config)
*
* Usage:
* Call SecurityHeadersMiddleware::apply() before any output.
* Then include this file (or extend it) in your view.
*
* Variables expected:
* $nonce string CSP nonce from SecurityHeadersMiddleware::getNonce()
* $currentUser array ['username', 'name', 'is_admin', 'groups']
* $pageTitle string Page <title> suffix
* $activeNav string Which nav link is active ('dashboard','tickets',etc.)
* $config array From config/config.php
*/
// Defensive defaults
$nonce = $nonce ?? '';
$currentUser = $currentUser ?? [];
$pageTitle = $pageTitle ?? 'Dashboard';
$activeNav = $activeNav ?? '';
$config = $config ?? [];
$isAdmin = $currentUser['is_admin'] ?? false;
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?php echo htmlspecialchars($pageTitle); ?> — <?php echo htmlspecialchars($config['APP_NAME'] ?? 'LotusGuild'); ?></title>
<meta name="description" content="LotusGuild infrastructure management">
<!-- Unified design system CSS -->
<link rel="stylesheet" href="/web_template/base.css">
<!-- App-specific CSS (extends base, never overrides variables without good reason) -->
<link rel="stylesheet" href="/assets/css/app.css?v=<?php echo $config['CSS_VERSION'] ?? '20260314'; ?>">
<link rel="icon" href="/assets/images/favicon.png" type="image/png">
</head>
<body>
<!-- Boot overlay — runs once per session via lt.boot.run() -->
<div id="lt-boot" class="lt-boot-overlay"
data-app-name="<?php echo htmlspecialchars($config['APP_NAME'] ?? 'APP'); ?>"
style="display:none">
<pre id="lt-boot-text" class="lt-boot-text"></pre>
</div>
<!-- =========================================================
HEADER
========================================================= -->
<header class="lt-header">
<div class="lt-header-left">
<div class="lt-brand">
<a href="/" class="lt-brand-title" style="text-decoration:none">
<?php echo htmlspecialchars($config['APP_NAME'] ?? 'APP'); ?>
</a>
<span class="lt-brand-subtitle"><?php echo htmlspecialchars($config['APP_SUBTITLE'] ?? 'LotusGuild Infrastructure'); ?></span>
</div>
<nav class="lt-nav" aria-label="Main navigation">
<a href="/" class="lt-nav-link <?php echo $activeNav === 'dashboard' ? 'active' : ''; ?>">Dashboard</a>
<a href="/tickets" class="lt-nav-link <?php echo $activeNav === 'tickets' ? 'active' : ''; ?>">Tickets</a>
<?php if ($isAdmin): ?>
<div class="lt-nav-dropdown">
<a href="#" class="lt-nav-link <?php echo str_starts_with($activeNav, 'admin') ? 'active' : ''; ?>">
Admin ▾
</a>
<ul class="lt-nav-dropdown-menu">
<li><a href="/admin/templates">Templates</a></li>
<li><a href="/admin/workflow">Workflow</a></li>
<li><a href="/admin/recurring-tickets">Recurring</a></li>
<li><a href="/admin/custom-fields">Custom Fields</a></li>
<li><a href="/admin/user-activity">User Activity</a></li>
<li><a href="/admin/audit-log">Audit Log</a></li>
<li><a href="/admin/api-keys">API Keys</a></li>
</ul>
</div>
<?php endif; ?>
</nav>
</div>
<div class="lt-header-right">
<?php if (!empty($currentUser['name'])): ?>
<span class="lt-header-user"><?php echo htmlspecialchars($currentUser['name']); ?></span>
<?php endif; ?>
<?php if ($isAdmin): ?>
<span class="lt-badge-admin">admin</span>
<?php endif; ?>
</div>
</header>
<!-- =========================================================
MAIN CONTENT — provided by the including view
========================================================= -->
<main class="lt-main lt-container">
<!-- CONTENT SLOT: the including view outputs its content here -->
<?php
// If using output buffering pattern:
// ob_start(); include 'views/DashboardView.php'; $content = ob_get_clean();
// echo $content;
//
// Or simply include views inline after including this layout.
?>
</main>
<!-- =========================================================
SCRIPTS
All <script> tags MUST include the CSP nonce attribute.
========================================================= -->
<!-- Inject runtime config + CSRF token (nonce required for CSP) -->
<script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
window.APP_TIMEZONE = '<?php echo htmlspecialchars($config['TIMEZONE'] ?? 'UTC'); ?>';
window.APP_TIMEZONE_ABBREV = '<?php echo htmlspecialchars($config['TIMEZONE_ABBREV'] ?? 'UTC'); ?>';
window.CURRENT_USER = {
id: <?php echo (int)($currentUser['user_id'] ?? 0); ?>,
username: <?php echo json_encode($currentUser['username'] ?? ''); ?>,
isAdmin: <?php echo $isAdmin ? 'true' : 'false'; ?>,
};
</script>
<!-- Unified design system JS -->
<script nonce="<?php echo $nonce; ?>" src="/web_template/base.js"></script>
<!-- App-specific JS (cache-busted) -->
<script nonce="<?php echo $nonce; ?>"
src="/assets/js/app.js?v=<?php echo $config['JS_VERSION'] ?? '20260314'; ?>">
</script>
<!-- Per-page inline JS goes here in the including view, e.g.: -->
<!--
<script nonce="<?php echo $nonce; ?>">
lt.sortTable.init('ticket-table');
lt.tableNav.init('ticket-table');
lt.keys.on('n', () => window.location.href = '/ticket/create');
lt.autoRefresh.start(() => fetch('/api/status').then(r=>r.json()).then(updateUI), 30000);
</script>
-->
</body>
</html>

92
python/auth.py Normal file
View File

@@ -0,0 +1,92 @@
"""
LOTUSGUILD TERMINAL DESIGN SYSTEM — Flask Auth Helpers
Provides Authelia SSO integration via remote headers.
Usage:
from auth import require_auth, get_user
@app.route('/my-page')
@require_auth
def my_page():
user = get_user()
return render_template('page.html', user=user)
"""
from functools import wraps
from flask import request, g
def get_user() -> dict:
"""
Parse Authelia SSO headers into a normalised user dict.
Authelia injects these headers after validating the session:
Remote-User → username / login name
Remote-Name → display name
Remote-Email → email address
Remote-Groups → comma-separated group list
"""
groups_raw = request.headers.get('Remote-Groups', '')
groups = [g.strip() for g in groups_raw.split(',') if g.strip()]
return {
'username': request.headers.get('Remote-User', ''),
'name': request.headers.get('Remote-Name', ''),
'email': request.headers.get('Remote-Email', ''),
'groups': groups,
'is_admin': 'admin' in groups,
}
def require_auth(f):
"""
Decorator that enforces authentication + group-based authorisation.
Reads allowed_groups from app config (key: 'auth.allowed_groups').
Falls back to ['admin'] if not configured.
Returns 401 if no user header present (request bypassed Authelia).
Returns 403 if user is not in an allowed group.
"""
@wraps(f)
def wrapper(*args, **kwargs):
from flask import current_app
user = get_user()
if not user['username']:
return (
'<h1 style="font-family:monospace;color:#ff4444">401 — Not Authenticated</h1>'
'<p style="font-family:monospace">Access this service through Authelia SSO.</p>',
401,
)
cfg = current_app.config.get('APP_CONFIG', {})
allowed = cfg.get('auth', {}).get('allowed_groups', ['admin'])
if not any(grp in allowed for grp in user['groups']):
return (
f'<h1 style="font-family:monospace;color:#ff4444">403 — Access Denied</h1>'
f'<p style="font-family:monospace">'
f'Account <strong>{user["username"]}</strong> is not in an allowed group '
f'({", ".join(allowed)}).</p>',
403,
)
# Store user on Flask's request context for convenience
g.user = user
return f(*args, **kwargs)
return wrapper
def require_admin(f):
"""
Stricter decorator — requires the 'admin' group regardless of config.
"""
@wraps(f)
@require_auth
def wrapper(*args, **kwargs):
user = get_user()
if not user['is_admin']:
return (
'<h1 style="font-family:monospace;color:#ff4444">403 — Admin Required</h1>',
403,
)
return f(*args, **kwargs)
return wrapper

141
python/base.html Normal file
View File

@@ -0,0 +1,141 @@
{#
LOTUSGUILD TERMINAL DESIGN SYSTEM — Flask/Jinja2 Base Template
Extend this in every page template:
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block active_nav %}dashboard{% endblock %}
{% block content %}
… your page HTML …
{% endblock %}
{% block scripts %}
<script nonce="{{ nonce }}">
lt.sortTable.init('my-table');
</script>
{% endblock %}
Required Flask setup (app.py):
- Pass `nonce` into every render_template() call via a context processor
- Pass `user` dict from _get_user() helper
- Pass `config` dict with APP_NAME, etc.
Context processor example:
@app.context_processor
def inject_globals():
import base64, os
nonce = base64.b64encode(os.urandom(16)).decode()
return dict(nonce=nonce, user=_get_user(), config=_config())
#}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Dashboard{% endblock %} — {{ config.get('app_name', 'LotusGuild') }}</title>
<meta name="description" content="LotusGuild infrastructure management">
<!-- Unified design system CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='../web_template/base.css') }}">
<!-- App-specific CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<link rel="icon" href="{{ url_for('static', filename='favicon.png') }}" type="image/png">
</head>
<body>
<!-- Boot overlay -->
<div id="lt-boot" class="lt-boot-overlay"
data-app-name="{{ config.get('app_name', 'APP') | upper }}"
style="display:none">
<pre id="lt-boot-text" class="lt-boot-text"></pre>
</div>
<!-- =========================================================
HEADER
========================================================= -->
<header class="lt-header">
<div class="lt-header-left">
<div class="lt-brand">
<a href="{{ url_for('index') }}" class="lt-brand-title" style="text-decoration:none">
{{ config.get('app_name', 'APP') | upper }}
</a>
<span class="lt-brand-subtitle">{{ config.get('app_subtitle', 'LotusGuild Infrastructure') }}</span>
</div>
<nav class="lt-nav" aria-label="Main navigation">
{# Each page sets {% block active_nav %}pagename{% endblock %} #}
{% set active = self.active_nav() | default('') %}
<a href="{{ url_for('index') }}"
class="lt-nav-link {% if active == 'dashboard' %}active{% endif %}">
Dashboard
</a>
<a href="{{ url_for('links_page') }}"
class="lt-nav-link {% if active == 'links' %}active{% endif %}">
Link Debug
</a>
<a href="{{ url_for('inspector') }}"
class="lt-nav-link {% if active == 'inspector' %}active{% endif %}">
Inspector
</a>
<a href="{{ url_for('suppressions_page') }}"
class="lt-nav-link {% if active == 'suppressions' %}active{% endif %}">
Suppressions
</a>
</nav>
</div>
<div class="lt-header-right">
{% if user.name or user.username %}
<span class="lt-header-user">{{ user.name or user.username }}</span>
{% endif %}
{% if 'admin' in user.groups %}
<span class="lt-badge-admin">admin</span>
{% endif %}
</div>
</header>
<!-- =========================================================
MAIN CONTENT
========================================================= -->
<main class="lt-main lt-container">
{% block content %}{% endblock %}
</main>
<!-- =========================================================
SCRIPTS
All <script> tags MUST carry the nonce attribute for CSP.
========================================================= -->
<!-- Runtime config (no CSRF needed for Gandalf — SameSite=Strict) -->
<script nonce="{{ nonce }}">
window.APP_CONFIG = {
ticketWebUrl: {{ config.get('ticket_api', {}).get('web_url', 'https://t.lotusguild.org/ticket/') | tojson }},
};
window.CURRENT_USER = {
username: {{ user.username | tojson }},
name: {{ (user.name or user.username) | tojson }},
groups: {{ user.groups | tojson }},
isAdmin: {{ ('admin' in user.groups) | lower }},
};
</script>
<!-- Unified design system JS -->
<script nonce="{{ nonce }}" src="{{ url_for('static', filename='../web_template/base.js') }}"></script>
<!-- App JS -->
<script nonce="{{ nonce }}" src="{{ url_for('static', filename='app.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
{# ---------------------------------------------------------------
active_nav block — override in each page template:
{% block active_nav %}dashboard{% endblock %}
Values: dashboard | links | inspector | suppressions
--------------------------------------------------------------- #}
{% block active_nav %}{% endblock %}