jared 45b968b77d audit pass 12: type=button, focus restore, :focus-visible on links
HTML:
- Add type="button" to all remaining buttons (nav drawer close, menu
  btn, theme toggle, notif bell, right drawer close, tab buttons,
  sidebar toggle, alert close x4, code copy, tab bar buttons x4,
  detail panel open, modal close x2, keyboard shortcuts close)
- Add aria-label="Search commands" to command palette input
- Notification panel close(true): restore focus to bell on Escape
- Generic dropdowns: add Escape key handler with trigger focus restore

CSS:
- Add a:focus-visible global focus ring
- Add .lt-nav-dropdown-menu li a:focus-visible
- Add .lt-markdown a:focus-visible
- Fix dead .lt-typeahead-option selector → .lt-typeahead-item with
  :hover, .is-focused, :focus-visible for light theme

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 13:49:23 -04:00

LotusGuild Terminal Design System v1.0

Unified layout, styling, and JavaScript utilities for all LotusGuild web applications.

Applies to: Tinker Tickets (PHP) · PULSE (Node.js) · GANDALF (Flask)


Quick Start

Core files (needed by every app):

base.css   → all variables, components, animations, responsive rules
base.js    → toast, modal, tabs, CSRF, fetch helpers, keyboard shortcuts
base.html  → full component reference (static demo, not for production)

Platform-specific helpers (one per backend):

php/layout.php          → PHP base layout with CSP nonce + CSRF injection
python/auth.py          → Flask @require_auth / @require_admin decorators
python/base.html        → Jinja2 base template with block inheritance
node/middleware.js      → Express requireAuth, csrfMiddleware, cspNonce, etc.

Then load them in your HTML:

<link rel="stylesheet" href="/web_template/base.css">
<!-- app-specific overrides after: -->
<link rel="stylesheet" href="/assets/css/app.css">

<script src="/web_template/base.js"></script>
<!-- app-specific JS after: -->
<script nonce="NONCE" src="/assets/js/app.js?v=YYYYMMDD"></script>

Aesthetic at a Glance

Property Value
Background #0a0a0a (near-black)
Primary accent #00ff41 (phosphor green)
Secondary accent #ffb000 (amber)
Tertiary accent #00ffff (cyan)
Error / critical #ff4444 (red)
Font 'Courier New', 'Consolas', 'Monaco', 'Menlo', monospace
Border radius 0 — no rounded corners ever
Box shadow Glow halos only (0 0 Xpx color) — no drop shadows
Borders 2px solid standard · 3px double for outer frames / modals
CRT effects Scanline animation · screen flicker at 30s · binary corner watermark

CSS Prefix

All classes use the .lt- prefix (LotusGuild Terminal) to prevent collisions with app-specific CSS.

.lt-btn           /* buttons */
.lt-card          /* cards */
.lt-frame         /* ASCII outer frames */
.lt-modal-overlay /* modals */
.lt-toast         /* toasts */
.lt-status-*      /* status badges */
.lt-p1  .lt-p5   /* priority badges */
/* etc. */

CSS Custom Properties

All colour, spacing, and layout values are CSS custom properties on :root. Override them per-app in your app.css:

/* Example: app-specific accent on top of base */
:root {
  --terminal-amber: #ffd000;   /* slightly brighter amber */
}

Key Variables

/* Backgrounds */
--bg-primary:   #0a0a0a
--bg-secondary: #1a1a1a
--bg-tertiary:  #2a2a2a
--bg-terminal:  #001a00

/* Terminal colours */
--terminal-green:  #00ff41
--terminal-amber:  #ffb000
--terminal-cyan:   #00ffff
--terminal-red:    #ff4444

/* Glow stacks (text-shadow) */
--glow-green
--glow-green-intense
--glow-amber
--glow-amber-intense
--glow-cyan
--glow-red

/* Box-shadow halos */
--box-glow-green
--box-glow-amber
--box-glow-red

/* Z-index ladder */
--z-dropdown: 100  --z-modal: 500  --z-toast: 800  --z-overlay: 9999

Components

Buttons

<button class="lt-btn">Default</button>
<button class="lt-btn lt-btn-primary">Primary</button>
<button class="lt-btn lt-btn-danger">Delete</button>
<button class="lt-btn lt-btn-sm">Small</button>
<button class="lt-btn lt-btn-ghost">Ghost</button>
<a href="/create" class="lt-btn lt-btn-primary">Link Button</a>

All buttons:

  • Transparent background, green border, [ text ] bracket decoration via ::before/::after
  • Hover → amber colour, translateY(-2px) lift, amber glow
  • border-radius: 0 — terminal style, no rounding

Status Badges

<span class="lt-status lt-status-open">Open</span>
<span class="lt-status lt-status-in-progress">In Progress</span>
<span class="lt-status lt-status-closed">Closed</span>
<span class="lt-status lt-status-online">Online</span>
<span class="lt-status lt-status-failed">Failed</span>

Spinning indicator on lt-status-in-progress and lt-status-running.

Priority Badges

<span class="lt-priority lt-p1">P1 Critical</span>
<span class="lt-priority lt-p2">P2 High</span>
<span class="lt-priority lt-p3">P3 Med</span>
<span class="lt-priority lt-p4">P4 Low</span>
<span class="lt-priority lt-p5">P5 Min</span>

P1 has a pulsing glow animation.

Chips (compact inline status)

<span class="lt-chip lt-chip-ok">OK</span>
<span class="lt-chip lt-chip-warn">Warn</span>
<span class="lt-chip lt-chip-critical">Critical</span>
<span class="lt-chip lt-chip-info">Info</span>

Inline Messages

<div class="lt-msg lt-msg-error">Something went wrong</div>
<div class="lt-msg lt-msg-success">Saved successfully</div>
<div class="lt-msg lt-msg-warning">Rate limit approaching</div>
<div class="lt-msg lt-msg-info">Auto-refresh every 30s</div>

ASCII Frames

Outer frame (double-line, for major sections):

<div class="lt-frame">
  <span class="lt-frame-bl"></span>
  <span class="lt-frame-br"></span>
  <div class="lt-section-header">Section Title</div>
  <div class="lt-section-body">
    <!-- content -->
  </div>
</div>

Inner frame (single-line, for sub-panels):

<div class="lt-frame-inner">
  <div class="lt-subsection-header">Sub Section</div>
  <!-- content -->
</div>

The ::before / ::after pseudo-elements draw the top-left / and top-right / corners automatically. Bottom corners require explicit <span> children because CSS cannot target :nth-pseudo-element.

Cards

<div class="lt-card">
  <div class="lt-card-title">Card Title</div>
  <!-- content -->
</div>

<!-- Grid of cards -->
<div class="lt-grid">        <!-- auto-fill, min 300px -->
<div class="lt-grid-2">      <!-- 2 columns -->
<div class="lt-grid-3">      <!-- 3 columns -->
<div class="lt-grid-4">      <!-- 4 columns -->

Tables

Full-border table (simple data, terminal look):

<div class="lt-table-wrap">
  <table class="lt-table" id="my-table">
    <thead><tr>
      <th data-sort-key="name">Name</th>
      <th data-sort-key="status">Status</th>
    </tr></thead>
    <tbody>
      <tr class="lt-row-p1 lt-row-critical">
        <td>ticket-001</td>
        <td><span class="lt-status lt-status-open">Open</span></td>
      </tr>
    </tbody>
  </table>
</div>

<!-- Wire sorting -->
<script>lt.sortTable.init('my-table');</script>

Data table (compact, row-only separators, for dense data):

<table class="lt-data-table"></table>

Row classes: lt-row-p1lt-row-p5 (priority left border) · lt-row-critical · lt-row-warning · lt-row-selected (keyboard nav highlight)

Forms

<div class="lt-form-group">
  <label class="lt-label lt-label-required" for="title">Title</label>
  <input id="title" type="text" class="lt-input" placeholder="…">
  <span class="lt-form-hint">Markdown supported.</span>
</div>

<div class="lt-form-group">
  <label class="lt-label" for="priority">Priority</label>
  <select id="priority" class="lt-select">
    <option>P1 — Critical</option>
  </select>
</div>

<div class="lt-form-group">
  <label class="lt-label">
    <input type="checkbox" class="lt-checkbox"> Confidential
  </label>
</div>

<!-- Search with prompt prefix -->
<div class="lt-search">
  <input type="search" class="lt-input lt-search-input" placeholder="Search…">
</div>

Labels are amber-coloured and uppercase. Inputs: green border, focus → amber border + glow pulse.

Modals

<!-- Trigger -->
<button class="lt-btn" data-modal-open="my-modal">Open Modal</button>

<!-- Modal -->
<div id="my-modal" class="lt-modal-overlay" aria-hidden="true">
  <div class="lt-modal">
    <div class="lt-modal-header">
      <span class="lt-modal-title">Modal Title</span>
      <button class="lt-modal-close" data-modal-close aria-label="Close"></button>
    </div>
    <div class="lt-modal-body">
      <!-- content -->
    </div>
    <div class="lt-modal-footer">
      <button class="lt-btn lt-btn-ghost" data-modal-close>Cancel</button>
      <button class="lt-btn lt-btn-primary">Confirm</button>
    </div>
  </div>
</div>
  • data-modal-open="id" → opens the 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

<div class="lt-tabs">
  <button class="lt-tab active" data-tab="panel-a">Tab A</button>
  <button class="lt-tab"        data-tab="panel-b">Tab B</button>
</div>

<div id="panel-a" class="lt-tab-panel active"></div>
<div id="panel-b" class="lt-tab-panel"></div>

<script>lt.tabs.init();</script>

Active tab persists across page reloads via localStorage.

Toast Notifications

lt.toast.success('Ticket #123 saved');
lt.toast.error('Connection refused', 5000);   // custom duration ms
lt.toast.warning('Rate limit 80% used');
lt.toast.info('Auto-refresh triggered');

Toasts appear bottom-right, stack vertically, auto-dismiss (default 3.5s), include an audible beep.

Stats Widgets

<div class="lt-stats-grid">
  <div class="lt-stat-card" data-filter-key="status" data-filter-val="Open">
    <span class="lt-stat-icon">📋</span>
    <div class="lt-stat-info">
      <span class="lt-stat-value">42</span>
      <span class="lt-stat-label">Open</span>
    </div>
  </div>
</div>

Clicking a stat card calls window.lt_onStatFilter(key, val) — implement this function in your app JS to wire it to your filter logic.

Sidebar

<aside class="lt-sidebar" id="lt-sidebar">
  <div class="lt-sidebar-header">
    Filters
    <button class="lt-sidebar-toggle" data-sidebar-toggle="lt-sidebar"></button>
  </div>
  <div class="lt-sidebar-body">
    <div class="lt-filter-group">
      <span class="lt-filter-label">Category</span>
      <label class="lt-filter-option">
        <input type="checkbox" class="lt-checkbox"> Hardware
      </label>
    </div>
  </div>
</aside>

Collapse state persists across page reloads via sessionStorage.

Boot Sequence

<div id="lt-boot" class="lt-boot-overlay" data-app-name="MY APP" style="display:none">
  <pre id="lt-boot-text" class="lt-boot-text"></pre>
</div>

Runs once per session. Shows animated ASCII boot sequence. Add data-app-name to customise the banner text.


JavaScript API (window.lt)

/* HTML safety */
lt.escHtml(str)                        // escape for innerHTML

/* Toasts */
lt.toast.success(msg, durationMs?)
lt.toast.error(msg, durationMs?)
lt.toast.warning(msg, durationMs?)
lt.toast.info(msg, durationMs?)

/* Audio */
lt.beep('success' | 'error' | 'info')  // Web Audio API beep; silent-fails if unavailable
// Note: toasts automatically call lt.beep() — only call directly for non-toast events

/* Modals */
lt.modal.open('modal-id')
lt.modal.close('modal-id')
lt.modal.closeAll()

/* Tabs */
lt.tabs.init()
lt.tabs.switch('panel-id')

/* Boot */
lt.boot.run('APP NAME', forceFlag?)

/* Keyboard shortcuts */
lt.keys.on('ctrl+s', handler)
lt.keys.off('ctrl+s')
lt.keys.initDefaults()            // ESC close, Ctrl+K focus search, ? help

/* Sidebar */
lt.sidebar.init()

/* CSRF */
lt.csrf.headers()                 // returns { 'X-CSRF-Token': token } or {}

/* Fetch helpers */
await lt.api.get('/api/tickets')
await lt.api.post('/api/update_ticket.php', { id: 1, status: 'Closed' })
await lt.api.put('/api/workflows/5', payload)
await lt.api.delete('/api/comment', { id: 9 })
// All throw Error on non-2xx with the server's error message

/* Time */
lt.time.ago(isoString)            // "5m ago"
lt.time.uptime(seconds)           // "14d 6h 3m"
lt.time.format(isoString)         // locale datetime string

/* Bytes */
lt.bytes.format(1234567)          // "1.18 MB"

/* Table utilities — must be called explicitly (not auto-initialized) */
lt.tableNav.init('table-id')      // j/k/Enter keyboard navigation on <tbody> rows
lt.sortTable.init('table-id')     // click-to-sort on <th data-sort-key> headers

/* Stats widget filtering */
lt.statsFilter.init()             // wires data-filter-key clicks

/* Auto-refresh */
lt.autoRefresh.start(fn, 30000)   // call fn every 30 s
lt.autoRefresh.stop()
lt.autoRefresh.now()              // trigger immediately + restart timer

CSRF Integration

PHP (Tinker Tickets)

<!-- In your view, using SecurityHeadersMiddleware nonce: -->
<script nonce="<?php echo $nonce; ?>">
  window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
  window.APP_TIMEZONE = '<?php echo $config['TIMEZONE']; ?>';
</script>

lt.api.* automatically includes the token as X-CSRF-Token header.

Node.js (PULSE)

// server.js: generate token and pass to template
res.render('index', { csrfToken: req.session.csrfToken });
<!-- EJS template -->
<script nonce="<%= nonce %>">window.CSRF_TOKEN = '<%= csrfToken %>';</script>

Flask (GANDALF)

GANDALF uses Authelia SSO with SameSite=Strict cookies so CSRF tokens are not required on API endpoints. For new apps using Flask-WTF:

<script nonce="{{ nonce }}">window.CSRF_TOKEN = '{{ csrf_token() }}';</script>

Auth Integration Patterns

PHP — Authelia headers

// AuthMiddleware.php
$user = [
    'username' => $_SERVER['HTTP_REMOTE_USER']  ?? '',
    'name'     => $_SERVER['HTTP_REMOTE_NAME']  ?? '',
    'email'    => $_SERVER['HTTP_REMOTE_EMAIL'] ?? '',
    'groups'   => explode(',', $_SERVER['HTTP_REMOTE_GROUPS'] ?? ''),
    'is_admin' => in_array('admin', explode(',', $_SERVER['HTTP_REMOTE_GROUPS'] ?? '')),
];

Python/Flask — @require_auth decorator

def require_auth(f):
    @wraps(f)
    def wrapper(*args, **kwargs):
        user = request.headers.get('Remote-User', '')
        if not user:
            return '401 Not Authenticated', 401
        groups = [g.strip() for g in request.headers.get('Remote-Groups','').split(',')]
        if 'admin' not in groups:
            return '403 Forbidden', 403
        return f(*args, **kwargs)
    return wrapper

Node.js — Express middleware

function requireAuth(req, res, next) {
  const user = req.headers['remote-user'];
  if (!user) return res.status(401).json({ error: 'Not authenticated' });
  req.user = {
    username: user,
    name:     req.headers['remote-name']   || user,
    email:    req.headers['remote-email']  || '',
    groups:   (req.headers['remote-groups'] || '').split(',').map(s => s.trim()),
    isAdmin:  (req.headers['remote-groups'] || '').split(',').includes('admin'),
  };
  next();
}

Framework Skeleton Files

File Framework Description
php/layout.php PHP / Tinker Tickets Complete page layout with CSP nonce, CSRF, session
python/base.html Flask / Jinja2 Jinja2 template extending pattern
python/auth.py Flask @require_auth decorator + _get_user()
node/middleware.js Node.js / Express Auth + CSRF + CSP nonce middleware

Directory Layout for New Apps

my-app/
├── assets/
│   ├── css/
│   │   └── app.css          # Extends base.css — app-specific rules only
│   ├── js/
│   │   └── app.js           # App logic — uses lt.* from base.js
│   └── images/
│       └── favicon.png
├── (php|python|node) files…
└── README.md                # Link back to web_template/README.md for styling docs

Responsive Breakpoints

Breakpoint Width Changes
XL > 1400px Full layout, 4-col grids
LG ≤ 1400px 3-col grids, 4-col stats
MD ≤ 1200px 2-col grids, narrow sidebar
SM (tablet) ≤ 1024px Sidebar stacks below content
XS (phone) ≤ 768px Nav hidden, single-col
XXS ≤ 480px Minimal padding, 2-col stats

Key Design Rules

  1. No rounded cornersborder-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 criticaltext-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 ✓)
S
Description
No description provided
Readme 2.1 MiB
Languages
CSS 41%
JavaScript 29.8%
HTML 25.8%
PHP 1.5%
EJS 1.2%
Other 0.7%