Compare commits

...

16 Commits

Author SHA1 Message Date
jared 0af46954d7 feat: back-port design system utilities from tinker_tickets to web_template
Lint / JS (eslint) (push) Successful in 10s
Sticky footer layout:
- body: add display:flex + flex-direction:column
- .lt-main: add flex:1 + width:100% + min-width:0 so it fills remaining height
- .lt-main.lt-container combined-selector specificity fix (prevents lt-container
  padding from overriding lt-main padding-top in responsive breakpoints)
- Responsive breakpoints updated to use .lt-main.lt-container combined selector

Modal utilities:
- .lt-modal-xs (280px), .lt-modal-sm (360px) — size modifiers
- .lt-modal-header--danger — danger variant (red bg tint + accent border/title)

Badge:
- .lt-badge-sm — compact size (0.5rem font, 0.05/0.3rem padding)

KV rows:
- .lt-kv-row (display:contents), .lt-kv-label, .lt-kv-value — alternate KV
  row pattern where children become direct lt-kv-grid items

Avatar image overlay:
- .lt-avatar: add position:relative so img can overlay initials
- .lt-avatar img: use absolute inset:0 overlay (was flat width/height)
- .lt-avatar img.lt-avatar-img-err: display:none when JS marks broken image

Extended markdown styles:
- list-style: disc/decimal + colored ::marker (cyan/orange)
- mark, del, sub, sup, task-item, task-cb, task-done, task-todo
- Footnote helpers: fn-ref, fn-hr, fn-list, fn-item, fn-back
- .md-image alias on img rule

Footer keyboard hints:
- .lt-footer-hints, .lt-footer-hint, .lt-footer-key, .lt-footer-sep

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 17:37:41 -04:00
jared 8df14ebbe3 fix: polish SLA banner component — gradient fill, dismiss persistence, terminal icons
Lint / JS (eslint) (push) Successful in 6s
- lt-sla-p2 .lt-sla-fill: upgrade from flat amber to gradient (#FFB300 →
  #ffd740) for visual consistency with P1 red→orange fill
- lt-sla-dismiss: add transition (0.15s ease) and :focus-visible outline so
  keyboard users get a visible focus ring
- Demo dismiss: replace .remove() with hidden + sessionStorage so banners
  stay dismissed across page navigation (data-sla-id attribute wires the key)
- Demo icons: swap emoji (🔴 🟠) for terminal-style text [ ! ] / [ ~ ] —
  emoji rendering is platform-specific and breaks the monospace aesthetic

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 17:27:10 -04:00
jared 39862fab3b feat: add SLA banner component and gradient progress bar fills from design test
Lint / JS (eslint) (push) Successful in 8s
SLA banners (.lt-sla-p1 / .lt-sla-p2):
- P1 pulsing red banner with lt-sla-pulse keyframe
- P2 static amber banner
- Subcomponents: lt-sla-icon, lt-sla-info, lt-sla-title, lt-sla-bar,
  lt-sla-fill, lt-sla-meta, lt-sla-dismiss
- Light theme overrides included
- Demo section added to base.html with dismiss wiring

Progress bar gradient fills:
- Default (orange), --cyan, --green, --red variants now use
  linear-gradient fills instead of flat accent colors for more
  dramatic terminal readout appearance

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 17:18:33 -04:00
jared 8f2f310fe2 Add unprefixed class aliases for monitoring app compatibility
Lint / JS (eslint) (push) Successful in 7s
Add .dot-up/.dot-down/.dot-degraded/.dot-unknown as aliases for lt-dot-* variants; add .chip/.chip-ok/.chip-warning/.chip-critical as aliases for lt-chip-* variants; add .lt-table tr.row-* state definitions for table row colouring.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 23:46:57 -04:00
jared 19d6a2883c docs: add new-app guide and EJS layout skeleton
Lint / JS (eslint) (push) Successful in 13s
README.md:
- Add 'Starting a New App' section with step-by-step instructions:
  nginx alias setup, which skeleton to copy, how to define nav for
  each framework, app.css pattern, lt.init() call
- Update File Structure section: remove app-specific labels from
  framework skeletons (was 'PHP / Tinker Tickets' etc.), add
  layout.ejs to the node/ listing

node/layout.ejs:
- New EJS base layout skeleton matching the PHP/Python equivalents:
  generic nav via navLinks locals, lt-* class names throughout,
  CSP nonce on all script tags, pageStyles/pageScripts arrays,
  CURRENT_USER + CSRF_TOKEN globals injected at runtime

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 14:00:01 -04:00
jared 75c57092f8 fix: make layout templates generic — remove app-specific nav and config
Lint / JS (eslint) (push) Successful in 10s
php/layout.php: nav is now data-driven via $navLinks array (supports
  top-level links, dropdowns, adminOnly flag); removed tinker_tickets
  hardcoded nav items; moved APP_CONFIG note to comment
python/base.html: nav driven by nav_links list from context; removed
  gandalf-specific routes (links_page, inspector, suppressions_page);
  removed APP_CONFIG.ticketWebUrl from shared script block; added
  nav_links format documentation in header comment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 13:53:29 -04:00
jared f61705afb8 docs: add Authelia portal integration guide
Lint / JS (eslint) (push) Successful in 12s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 16:30:39 -04:00
jared 140a57a029 docs: add CI lint badge to README
Lint / JS (eslint) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 16:28:23 -04:00
jared f9445ed5ae ci: add ESLint workflow
Lint / JS (eslint) (push) Has been cancelled
- .gitea/workflows/lint.yml: lint base.js and node/ directory
- .eslintrc.json: browser + node environment, CommonJS
- .gitignore: ignore node_modules/ and .env
- package.json + package-lock.json: eslint@8 dev dependency

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 16:25:53 -04:00
jared 044adb3a18 Fix nav dropdown dismissing when cursor moves into menu
top:calc(100%+4px) left a 4px dead zone between the trigger and
menu that broke :hover continuity. Changed to top:100% with
padding-top:6px + margin-top:-2px so the hoverable area is
contiguous. Updated ::before decorative line to top:6px to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 00:43:33 -04:00
jared 842190d225 fix: light theme override for lt-nav-dropdown-menu
Menu had hardcoded dark background (rgba(6,12,20,0.98)) with no light
theme rule, leaving it black regardless of theme toggle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:59:57 -04:00
jared a807944b50 feat: implement RECOMMENDED_ADDITIONS — cursor, scanlines, radar, display-field, VT323
- --font-crt: 'VT323' token added to :root; VT323 added to Google Fonts link
- .lt-cursor / .lt-cursor--cyan/orange/red — blinking block cursor via CSS ::after
- .lt-scanlines — opt-in CRT horizontal scanline overlay on body/container (light-mode suppressed)
- .lt-radar / --sm / --lg / --green — radar sweep loading indicator as lt-spinner alternative
- .lt-display-field — readable non-editable field variant (distinct from :disabled opacity:0.45)
- base.html demos: radar variants in loading row, display-field in forms, cursor+VT323 in tags section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 16:50:46 -04:00
jared 1b7e57d9f5 Add gotchas section: disabled/readonly display patterns
Documents the opacity:0.45 behavior on :disabled and [readonly] elements
and the correct workarounds for display-only contexts (edit-mode toggle
selects, copy inputs, pre-wrap description areas).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 19:43:49 -04:00
jared 083a918729 docs: add LDAP avatar integration guide to README
Documents the lldap service account setup, avatar endpoint pattern,
CSS photo-over-initials approach, and HTML template used in tinker_tickets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 21:04:57 -04:00
jared de30ff13e6 fix: add in-memory _bootFired flag to runBoot as primary dedup guard
sessionStorage alone could be bypassed in hash-URL edge cases. A module-scoped
_bootFired flag blocks any second runBoot call within the same JS context
regardless of sessionStorage state, then sessionStorage handles cross-reload
suppression. Also restores boot call in private init() to preserve original
dual-path structure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 14:52:31 -04:00
jared 32f7b49f98 fix: remove boot call from private init() to prevent hash-URL double-boot
Private init() and lt.init() both called runBoot(), creating two code paths.
With base.html# URLs the sessionStorage guard could be bypassed causing every
boot line to appear twice. Boot is now exclusively triggered by lt.init() or
lt.boot.run(), which already has its own _ltInitialized guard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 14:46:34 -04:00
13 changed files with 2547 additions and 76 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"env": { "browser": true, "node": true, "es2021": true },
"parserOptions": { "ecmaVersion": 2021, "sourceType": "script" },
"rules": {
"no-unused-vars": "warn",
"no-undef": "warn",
"no-empty": "warn",
"semi": ["error", "always"],
"eqeqeq": "warn"
}
}
+20
View File
@@ -0,0 +1,20 @@
name: Lint
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
js-lint:
name: JS (eslint)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install ESLint
run: npm install --save-dev eslint@8
- name: Run ESLint
run: npx eslint --ext .js base.js node/
+3
View File
@@ -0,0 +1,3 @@
node_modules/
.env
*.env
+495
View File
@@ -0,0 +1,495 @@
# Authelia Portal — LotusGuild Terminal Design System Integration
This document covers everything that had to be **written from scratch** to theme the
Authelia authentication portal using the LotusGuild Terminal Design System v1.2.
It is a companion to `base.css` / `base.js` for future integrations with third-party
applications that use their own component frameworks.
---
## Why a separate file at all?
`base.css` targets exclusively `.lt-*` class names — the design system's own prefix.
Authelia's frontend is a React single-page application built on
**Material UI (MUI) v5**, which generates its own stable class names like
`.MuiCard-root`, `.MuiButton-containedPrimary`, etc.
Bridging the two requires a translation layer: the same design tokens and visual
patterns from `base.css`, re-expressed against MUI's class structure.
Additionally, Authelia v4.x (latest: 4.39.x) does **not** support native CSS
injection — `server.asset_path` only handles `logo.png` and `favicon.ico`.
The CSS is injected via **nginx `sub_filter`** in Nginx Proxy Manager, which rewrites
Authelia's HTML on the fly to include a `<link>` tag before `</head>`.
---
## Deployment architecture
```
Browser
└─► https://auth.lotusguild.org (NPM — LXC 139)
├─ GET /custom.css ──────── served from /data/custom_assets/authelia-custom.css
│ (nginx alias, no upstream request)
└─ GET /* ──────────────── proxied to Authelia (LXC 167 :9091)
HTML response is rewritten by sub_filter:
</head> → <link rel="stylesheet" href="/custom.css"></head>
```
**NPM config changed:** `/data/nginx/proxy_host/29.conf` (auth.lotusguild.org proxy host)
```nginx
# Serve LotusGuild Terminal custom CSS
location = /custom.css {
alias /data/custom_assets/authelia-custom.css;
add_header Content-Type "text/css";
add_header Cache-Control "public, max-age=3600";
}
location / {
...existing headers...
# Prevent upstream gzip so sub_filter can inspect the body
proxy_set_header Accept-Encoding "";
# Inject CSS link before </head>
sub_filter '</head>' '<link rel="stylesheet" href="/custom.css"></head>';
sub_filter_once on;
include /etc/nginx/conf.d/include/proxy.conf;
}
```
> **Warning:** NPM regenerates proxy_host conf files when you edit a proxy host in
> the UI. If host 29 is ever modified through NPM, re-apply the `sub_filter` block
> and the `/custom.css` location manually, or set the config via NPM's
> "Advanced" tab before saving.
**Authelia config changed:** `/etc/authelia/configuration.yml` — added `asset_path`
under `server:` so Authelia serves the resized logo and favicon:
```yaml
server:
address: tcp://0.0.0.0:9091
asset_path: /etc/authelia/assets
```
Assets in `/etc/authelia/assets/`:
- `logo.png` — 256 × 256 PNG resized from Tinker Tickets `assets/images/favicon.png`
- `favicon.ico` — 32 × 32 ICO from the same source
---
## What is NOT in `base.css` and had to be written
The sections below are ordered as they appear in `authelia-custom.css`.
---
### 1. Re-declaring design tokens with `!important`
**In `base.css`:** `:root { ... }` is declared once, without `!important`.
**What had to be added:** The entire `:root` block is repeated in `custom.css` because
Authelia's HTML is a self-contained SPA — `base.css` is never loaded. More critically,
MUI injects its own inline styles and CSS-in-JS rules at very high specificity.
Every property that needs to override MUI requires `!important`. `base.css` never
uses `!important` because it owns its namespace; here we are guests in MUI's DOM.
```css
/* base.css does NOT use !important anywhere */
body { background-color: var(--bg-primary); }
/* custom.css must fight MUI's inline styles */
body { background-color: var(--bg-primary) !important; }
```
The token values themselves are identical to `base.css` — only the override
mechanism is new.
---
### 2. Universal box-sizing reset with `!important`
**In `base.css`:** `*, *::before, *::after { box-sizing: border-box; }` — no flag needed
because `base.css` loads first.
**What had to be added:**
```css
*, *::before, *::after {
box-sizing: border-box !important;
}
```
MUI components set `box-sizing` inline on some elements. Without the flag the
clip-path geometry breaks on inputs and buttons.
---
### 3. MUI typography selectors
**In `base.css`:** Typography rules target `h1h6`, `a`, `p`, `body` — standard HTML
elements. No MUI class names exist in the design system.
**What had to be added:** Authelia renders text almost exclusively through MUI's
`<Typography>` component, which produces elements with `.MuiTypography-root` and
variant classes. Without targeting these, all text inherits MUI's Roboto font and
light grey colour instead of JetBrains Mono and `--text-primary`.
```css
.MuiTypography-root,
.MuiInputBase-input,
.MuiFormLabel-root,
.MuiFormHelperText-root,
label, p, span, div {
font-family: var(--font-mono) !important;
color: var(--text-primary) !important;
}
h1, h2, h3, h4, h5, h6,
.MuiTypography-h5,
.MuiTypography-h6 {
color: var(--accent-orange) !important;
text-shadow: var(--glow-orange) !important;
...
}
```
---
### 4. `.MuiCard-root` / `.MuiPaper-root` — the login card
**In `base.css`:** `.lt-card` implements the terminal card with `clip-path`, border,
background, and the `::before` corner triangle accent.
**What had to be added:** Authelia wraps its login form in a MUI `<Card>` (which
extends `<Paper>`). Neither selector exists in `base.css`. The visual result is
identical to `.lt-card` — the clip-path polygon, cyan border, box-glow, and
corner triangle are all copied — but they target different class names and require
`!important` throughout to override MUI's elevation shadows and `border-radius: 4px`.
```css
.MuiCard-root,
.MuiPaper-root {
background: var(--bg-card) !important;
border: 1px solid var(--border-color) !important;
border-radius: 0 !important; /* ← overrides MUI default */
clip-path: polygon(...) !important; /* same geometry as .lt-card */
...
}
.MuiCard-root::before,
.MuiPaper-root::before {
/* same corner triangle as .lt-card::before */
}
```
---
### 5. Logo element — `img[alt="Authelia"]`
**In `base.css`:** No equivalent. The design system has no logo slot component.
**What had to be added:** Authelia renders the portal logo as
`<img alt="Authelia" src="./static/media/logo.png">`. This selector targets it
specifically to apply the cyan drop-shadow and orange hover glow. Without it the
logo renders without any terminal aesthetic treatment.
```css
img[alt="Authelia"] {
filter: drop-shadow(0 0 12px rgba(0,212,255,0.4)) !important;
transition: filter 0.25s ease !important;
}
img[alt="Authelia"]:hover {
filter: drop-shadow(0 0 18px rgba(255,107,0,0.5)) !important;
}
```
---
### 6. MUI outlined input family
**In `base.css`:** `.lt-input`, `.lt-select`, `.lt-textarea` — custom elements with
`clip-path`, terminal background, and cyan focus ring.
**What had to be added:** MUI's outlined text field is a composition of three
separate elements that each need individual targeting:
| MUI class | What it controls | Equivalent in `base.css` |
|-----------|-----------------|--------------------------|
| `.MuiOutlinedInput-root` | Outer wrapper — background, clip-path | `.lt-input` outer |
| `.MuiOutlinedInput-notchedOutline` | The SVG border element | `.lt-input` border |
| `.MuiInputBase-input` | The actual `<input>` inside | `.lt-input` inner text |
| `.MuiInputLabel-root` | Floating label | `.lt-label` |
| `.MuiFormHelperText-root` | Error / hint text below field | `.lt-form-hint` |
MUI splits the border rendering into a separate SVG `<fieldset>` element
(`.MuiOutlinedInput-notchedOutline`), which requires its own border-color override.
`base.css` has no equivalent splitting since `.lt-input` is a single element with a
CSS border.
The `.Mui-focused` and `.Mui-error` state classes also need explicit overrides because
MUI applies its own colour through CSS-in-JS with high specificity.
```css
.MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline {
border-color: var(--accent-cyan) !important;
box-shadow: var(--box-glow-cyan) !important;
}
.MuiFormHelperText-root.Mui-error {
color: var(--accent-red) !important;
text-shadow: var(--glow-red) !important;
}
```
Additionally, `caret-color` is set explicitly on the input because MUI does not
expose this through its theme — the blinking text cursor would otherwise be white.
---
### 7. MUI button family
**In `base.css`:** `.lt-btn`, `.lt-btn-primary`, `.lt-btn-danger`, `.lt-btn-ghost`
all using `clip-path` hexagon cuts, transparent backgrounds, and border+glow styling.
**What had to be added:** MUI uses four separate button variant classes. The primary
(sign-in) button is `.MuiButton-containedPrimary` which by default renders as a
solid filled rectangle. The entire button appearance — transparent background,
border, clip-path, text-transform, letter-spacing — has to be explicitly overridden
because MUI's `contained` variant applies an opaque `background-color` inline.
```css
/* MUI default: background-color: #1976d2; border-radius: 4px; box-shadow: ... */
.MuiButton-containedPrimary {
background: transparent !important; /* fight the inline fill */
border: 1px solid var(--accent-orange-border) !important;
border-radius: 0 !important;
clip-path: polygon(...) !important;
...
}
```
Text/link buttons (`.MuiButton-text`) are separate from contained buttons in MUI but
map to the same `.lt-btn-ghost` pattern in `base.css`.
---
### 8. MUI checkbox and switch
**In `base.css`:** `.lt-checkbox`, `.lt-switch` — fully custom-drawn using CSS only.
**What had to be added:** Authelia uses MUI's `<Checkbox>` (remember-me) and
`<Switch>` (settings toggles). These are SVG-based components. Because MUI controls
their colour through injected CSS variables, the only reliable override is to target
`.Mui-checked` state classes and use `!important`. There is no equivalent split
between `track` and `thumb` in `base.css`.
```css
.MuiSwitch-switchBase.Mui-checked { color: var(--accent-cyan) !important; }
.MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track {
background-color: var(--accent-cyan) !important;
opacity: 0.5 !important;
}
```
---
### 9. MUI alert / notification banners
**In `base.css`:** `.lt-inline-*` for inline messages (`.lt-inline-success`,
`.lt-inline-error`, etc.) and `.lt-toast-*` for toasts. These use a left border
accent pattern.
**What had to be added:** Authelia uses MUI `<Alert>` for login errors, success
messages, and 2FA prompts. MUI Alert has its own severity system with four variant
classes (`.MuiAlert-standardError`, etc.). Each severity needs explicit colour,
background, and border-color overrides. The `border-left: 3px solid currentColor`
pattern is adapted from `base.css`'s inline message style but applied to MUI's
class names with `clip-path: none !important` to cancel MUI's rounded corners.
---
### 10. MUI select, menu, and popover
**In `base.css`:** `.lt-select` for the select element itself; no dropdown overlay
component exists since the design system uses native `<select>`.
**What had to be added:** MUI's `<Select>` renders its dropdown as a `<Paper>`
element portalled to `<body>` with class `.MuiMenu-paper`. Without targeting this
separately, the dropdown popup inherits the browser default white background and
Roboto font, appearing completely unstyled. `.MuiMenuItem-root` hover and selected
states also need individual rules.
---
### 11. OTP / 2FA digit inputs — `input[type="tel"]`, `input[type="number"]`
**In `base.css`:** No equivalent. The design system has no numeric code input.
**What had to be added:** Authelia's one-time-password entry uses `<input type="tel">`
elements arranged in a row. These need oversized, centered, cyan-glowing characters
with wide letter-spacing to render like a retro code display.
```css
input[type="tel"],
input[type="number"] {
font-size: 1.2rem !important;
font-weight: 700 !important;
color: var(--accent-cyan) !important;
text-shadow: var(--glow-cyan) !important;
letter-spacing: 0.15em !important;
text-align: center !important;
caret-color: var(--accent-orange) !important;
}
```
---
### 12. MUI stepper — 2FA flow breadcrumb
**In `base.css`:** `.lt-stepper`, `.lt-step`, `.lt-step-num` — a custom CSS stepper
for multi-step wizard flows.
**What had to be added:** Authelia renders a MUI `<Stepper>` at the top of the 2FA
flow showing steps like "Username → Password → TOTP". MUI Stepper uses distinct
classes for label, icon, and connector that don't map 1-to-1 to `.lt-step-*`.
The step icon is an SVG circle rendered by React so it can only be tinted via
`color` and `filter: drop-shadow(...)`. The connector line between steps is a
separate `<hr>` with class `.MuiStepConnector-line`.
```css
.MuiStepIcon-root.Mui-active {
color: var(--accent-orange) !important;
filter: drop-shadow(0 0 6px rgba(255,107,0,0.6)) !important;
}
.MuiStepIcon-root.Mui-completed {
color: var(--accent-green) !important;
}
```
---
### 13. MUI linear and circular progress
**In `base.css`:** `.lt-progress` / `.lt-progress-bar` — a standard CSS progress bar
using `width` transitions. `.lt-spinner` — a rotating pseudo-element spinner.
**What had to be added:** Authelia uses MUI `<LinearProgress>` during API calls and
`<CircularProgress>` as the main loading spinner. MUI Linear Progress animates its
own internal elements (`.MuiLinearProgress-bar`) with keyframe animations; it cannot
be replaced with the `.lt-progress` pattern. The cyan-to-orange gradient and
`box-shadow` glow are new here — `base.css`'s progress bar uses a solid
`--accent-orange` fill.
```css
.MuiLinearProgress-bar {
background: linear-gradient(90deg, var(--accent-cyan), var(--accent-orange)) !important;
box-shadow: 0 0 8px rgba(0,212,255,0.5) !important;
}
```
---
### 14. MUI tooltip
**In `base.css`:** `.lt-tooltip` — a CSS-only tooltip shown via `:hover` on a
`[data-tooltip]` attribute.
**What had to be added:** MUI `<Tooltip>` portals its bubble to `<body>` with class
`.MuiTooltip-tooltip`. It is a separate DOM node, not a pseudo-element, so
`.lt-tooltip` styles cannot reach it. A standalone rule sets the terminal background,
monospace font, and squared border.
---
### 15. `*:focus-visible` global ring
**In `base.css`:** Focus rings are defined individually per component
(`.lt-btn:focus-visible`, `.lt-input:focus-visible`, etc.).
**What had to be added:** A single global rule is more practical here because
Authelia contains many interactive MUI elements not known at design time, and
adding per-component rules for every possible focusable element would be brittle.
```css
*:focus-visible {
outline: 2px solid var(--accent-cyan) !important;
outline-offset: 2px !important;
box-shadow: var(--box-glow-cyan) !important;
}
```
---
### 16. Branding footer watermark — `#root::after`
**In `base.css`:** No equivalent. The design system does not add page-level
watermarks.
**What had to be added:** Authelia has no footer. The `#root::after` pseudo-element
attaches a fixed-position text watermark reading
`LOTUSGUILD SECURE PORTAL // AUTH.LOTUSGUILD.ORG` at the bottom of the viewport.
This uses `--text-muted`, `--font-mono`, uppercase, and wide letter-spacing to match
the terminal aesthetic without adding any HTML.
```css
#root::after {
content: 'LOTUSGUILD SECURE PORTAL // AUTH.LOTUSGUILD.ORG';
position: fixed;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
font-size: 0.55rem;
letter-spacing: 0.18em;
color: var(--text-muted);
opacity: 0.6;
pointer-events: none;
}
```
---
## Patterns shared with `base.css` (not new)
The following are **not** documented above because they are direct re-uses of
existing `base.css` patterns, only re-targeted to new selectors:
| Pattern | `base.css` source | Re-used in `custom.css` for |
|---------|------------------|-----------------------------|
| Dot-grid background | `html { background-image: radial-gradient(...) }` | `body` |
| Scanlines overlay | `body::before` | `body::before` (same rule, `!important` added) |
| Corner vignette | `body::after` | `body::after` (same rule, `!important` added) |
| Card clip-path polygon | `.lt-card { clip-path: polygon(...) }` | `.MuiCard-root` |
| Card corner triangle | `.lt-card::before` | `.MuiCard-root::before` |
| Button clip-path hexagon | `.lt-btn { clip-path: polygon(...) }` | `.MuiButton-containedPrimary` |
| Input clip-path cut | `.lt-input { clip-path: polygon(...) }` | `.MuiOutlinedInput-root` |
| Scrollbar | `::-webkit-scrollbar-*` section 45 | identical, `border-radius: 0` added |
| Link colours | `a { color: var(--accent-cyan) }` | `a` (same, `!important` added) |
| Divider | `.lt-divider` border-color | `.MuiDivider-root` |
---
## Notes for future third-party integrations
1. **Always check for `!important` requirements.** CSS-in-JS frameworks (MUI, Emotion,
styled-components) inject styles at runtime with high specificity. Without
`!important`, most design token overrides will lose to injected inline styles.
2. **The split border problem.** MUI (and many component libraries) separate the
border from the element it visually belongs to. Always inspect the actual DOM
before writing selectors — what looks like one element is often three.
3. **Portalled overlays.** Dropdowns, tooltips, and modals in MUI are rendered into
a separate DOM tree (`<body>` portal). Scoped selectors inside a card or form
won't reach them. Target their root classes (`.MuiMenu-paper`, `.MuiTooltip-tooltip`)
directly.
4. **State classes.** MUI uses `.Mui-focused`, `.Mui-checked`, `.Mui-error`,
`.Mui-active`, `.Mui-completed`, `.Mui-disabled` as state modifiers. These are the
equivalent of `:focus`, `:checked`, etc. but applied by JavaScript — pseudo-class
selectors alone will sometimes not fire.
5. **SVG-based components.** Checkboxes, radio buttons, step icons, and spinners are
SVGs injected by React. They cannot be styled with `background`, `border`, or
`clip-path`. Use `color` (SVG `currentColor` inheritance) and `filter:
drop-shadow()` instead.
+267 -6
View File
@@ -1,5 +1,7 @@
# LotusGuild Terminal Design System — v1.2
[![Lint](https://code.lotusguild.org/LotusGuild/web_template/actions/workflows/lint.yml/badge.svg)](https://code.lotusguild.org/LotusGuild/web_template/actions?workflow=lint.yml)
**Aesthetic:** Anduril × Hacker Terminal — dark military-tech, multi-accent neon, angular clip-path frames, glitch effects.
A single-file design system (`base.css` + `base.js`) used across all LotusGuild internal services. `base.html` is a living reference template that demonstrates every component and pattern.
@@ -19,7 +21,8 @@ A single-file design system (`base.css` + `base.js`) used across all LotusGuild
9. [Theming (Dark / Light)](#theming)
10. [Accessibility](#accessibility)
11. [File Structure](#file-structure)
12. [Changelog](#changelog)
12. [LDAP Avatar Integration](#ldap-avatar-integration)
13. [Changelog](#changelog)
---
@@ -892,22 +895,280 @@ Set `data-theme="light"` on `<html>` directly. All component styles react throug
---
## Starting a New App
### 1. Serve the design system files
Add an nginx alias so every app on the same host can reference the same files:
```nginx
# In each app's server block (or a shared include):
location /web_template/ {
alias /path/to/web_template/;
expires 7d;
add_header Cache-Control "public, immutable";
}
```
Then in your HTML:
```html
<link rel="stylesheet" href="/web_template/base.css">
<script src="/web_template/base.js"></script>
```
### 2. Copy the right skeleton
| Stack | Copy this file | Into your app as |
|-------|---------------|-----------------|
| PHP | `php/layout.php` | `views/layout.php` (or `layout_header.php`) |
| Python/Flask | `python/base.html` | `templates/base.html` |
| Node/Express | `node/middleware.js` + `node/layout.ejs` | `middleware.js` + `views/layout.ejs` |
### 3. Define your nav
**PHP** — pass `$navLinks` before including the layout:
```php
$navLinks = [
['href' => '/', 'key' => 'dashboard', 'label' => 'Dashboard'],
['href' => '/reports', 'key' => 'reports', 'label' => 'Reports'],
['label' => 'Admin', 'key' => 'admin', 'adminOnly' => true, 'children' => [
['href' => '/admin/users', 'label' => 'Users'],
]],
];
$activeNav = 'dashboard';
include __DIR__ . '/views/layout.php';
```
**Python/Flask** — inject via context processor or pass directly to `render_template`:
```python
nav_links = [
{'href': url_for('index'), 'key': 'dashboard', 'label': 'Dashboard'},
{'href': url_for('settings'), 'key': 'settings', 'label': 'Settings'},
]
return render_template('page.html', nav_links=nav_links)
```
**Node/Express** — set on `res.locals` via `injectLocals` middleware:
```js
app.use((req, res, next) => {
res.locals.navLinks = [
{ href: '/', key: 'dashboard', label: 'Dashboard' },
{ href: '/workers', key: 'workers', label: 'Workers' },
];
next();
});
```
### 4. Add app-specific CSS
Create `app.css` in your app. Import nothing from `base.css` — just override tokens or add components:
```css
/* app.css — app-specific extensions only */
:root {
--app-accent: #FF6B00; /* override if needed */
}
/* Only put styles here that aren't already in base.css */
```
### 5. Initialise
In your base template, after `base.js`:
```html
<script nonce="{{ nonce }}">
lt.init({ bootName: 'MY APP' });
</script>
```
---
## File Structure
```
web_template/
├── base.css Design system styles (79 sections, ~5,200 lines)
├── base.js Design system JS (55+ modules, ~2,800 lines)
├── base.html Living reference template
├── base.html Living component reference — open in browser to browse everything
├── README.md This file
── (framework skeletons)
├── php/ PHP / Tinker Tickets
├── python/ Flask / Jinja2 / GANDALF
└── node/ Express / EJS / PULSE
── AUTHELIA_INTEGRATION.md Theming Authelia portal with this design system
└── framework skeletons/
├── php/
│ └── layout.php Generic PHP base layout (pass $navLinks)
├── python/
│ ├── base.html Jinja2 base template (pass nav_links list)
│ └── auth.py Authelia SSO helper for Flask
└── node/
├── middleware.js Express middleware (auth, CSRF, nonce, rate limit)
└── layout.ejs EJS base template (uses res.locals.navLinks)
```
---
## LDAP Avatar Integration
`lt-avatar` components support real profile photos pulled from **lldap** (the LotusGuild LDAP server). Photos overlay the initials fallback — if a user has no photo the initials show instead; no broken images.
### Infrastructure (one-time, per app)
**1. Create a service account in lldap**
Log into lldap at `http://10.10.10.39:17170`, or use the GraphQL API:
```bash
TOKEN=$(curl -s -X POST http://10.10.10.39:17170/auth/simple/login \
-H 'Content-Type: application/json' \
-d '{"username":"<admin>","password":"<pw>"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
# Create service account
curl -s -X POST http://10.10.10.39:17170/api/graphql \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"query":"mutation { createUser(user: { id: \"my-app\", email: \"my-app@lotusguild.org\", displayName: \"My App Service\" }) { id } }"}'
# Add to lldap_strict_readonly (id=3) for directory read access
curl -s -X POST http://10.10.10.39:17170/api/graphql \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"query":"mutation { addUserToGroup(userId: \"my-app\", groupId: 3) { ok } }"}'
```
Then set the password via `ldappasswd`:
```bash
ldappasswd -H ldap://10.10.10.39:3890 \
-D "uid=<admin>,ou=people,dc=example,dc=com" \
-w '<admin-pw>' \
-s '<service-account-pw>' \
"uid=my-app,ou=people,dc=example,dc=com"
```
**2. Install php-ldap and create the avatar cache directory**
```bash
apt-get install -y php8.2-ldap
mkdir -p /var/www/html/myapp/uploads/avatars
chown -R www-data:www-data /var/www/html/myapp/uploads/avatars
```
**3. Add LDAP config to `.env`**
```ini
LDAP_ENABLED=true
LDAP_HOST=10.10.10.39
LDAP_PORT=3890
LDAP_BIND_DN="uid=my-app,ou=people,dc=example,dc=com"
LDAP_BIND_PW="<service-account-pw>"
LDAP_BASE_DN="dc=example,dc=com"
LDAP_USER_BASE="ou=people,dc=example,dc=com"
AVATAR_CACHE_TTL=3600
```
> **Note:** lldap is currently configured with `dc=example,dc=com` as the base DN across all services (Authelia, etc.). Do not change this per-app — it requires a coordinated infrastructure migration.
### Avatar Endpoint (`/api/user_avatar.php`)
Copy the reference implementation from `tinker_tickets/api/user_avatar.php`. It:
1. Requires a valid session (returns 401 otherwise)
2. Accepts `?user_id=N` and looks up the user's `username` from the app's DB
3. Binds to lldap and searches `ou=people,dc=example,dc=com` with filter `(uid={username})`
4. Fetches the `avatar` attribute (raw binary JPEG, returned as-is by `ldap_get_entries()`)
5. Validates JPEG magic bytes (`\xFF\xD8\xFF`) and writes to `uploads/avatars/user_{id}.jpg`
6. Writes a `.none` sentinel file for users with no avatar so lldap is not queried again until TTL expires
7. Serves the cached file with `Content-Type: image/jpeg`
### CSS — Photo-over-Initials Pattern
`base.css` (Section 62 — Avatar) provides `.lt-avatar-img` and `.lt-avatar-initials`:
```css
.lt-avatar { position: relative; }
.lt-avatar-initials { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
.lt-avatar-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; border-radius: inherit; z-index: 1; }
```
The photo sits above the initials. The `onerror` handler hides the image if the endpoint returns 404 (no avatar), letting the initials show through.
### HTML Pattern
```php
<?php
$words = array_filter(explode(' ', $displayName));
$initials = strtoupper(implode('', array_map(fn($w) => $w[0], array_slice($words, 0, 2))));
$colors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
$color = $colors[abs(crc32($displayName)) % count($colors)];
?>
<div class="lt-avatar lt-avatar--sm <?= $color ?>" aria-hidden="true">
<?php if ($userId > 0): ?>
<img src="/api/user_avatar.php?user_id=<?= $userId ?>"
alt=""
class="lt-avatar-img"
onerror="this.style.display='none'">
<?php endif ?>
<span class="lt-avatar-initials"><?= htmlspecialchars($initials) ?></span>
</div>
```
Key points:
- Always render the initials — they are the fallback, never a broken state
- The `onerror` on the `<img>` hides it when the endpoint returns 404 (no photo set)
- Color is deterministically derived from the display name so it's consistent across page loads
- `aria-hidden="true"` because the avatar is purely decorative; the user's name appears in adjacent text
---
## Known Patterns & Gotchas
### Disabled / read-only form elements in display-only contexts
`base.css` applies aggressive visibility reduction to `:disabled` and `[readonly]` form elements:
```css
.lt-input:disabled, .lt-select:disabled, .lt-textarea:disabled,
.lt-input[readonly], .lt-textarea[readonly] {
opacity: 0.45;
color: var(--text-muted); /* #3e607a — ~3.2:1 contrast, fails WCAG AA on dark backgrounds */
}
.lt-checkbox:disabled { opacity: 0.4; }
```
This is intentional for *genuinely* disabled controls (submission forms, locked fields). However, if you use `disabled` purely to make a field **non-interactive for display**, the result is nearly unreadable on dark/OLED screens.
**Pattern: display-only selects / inputs (edit-mode toggle)**
A common pattern is disabling fields in view-mode and enabling them in edit-mode. Apply a scoping class and override in your app CSS:
```css
/* In your app's CSS — override base.css fading for display-only fields */
.your-display-field:disabled,
.your-display-field[disabled] {
opacity: 1;
color: var(--text-secondary); /* #7fa3bf — full legibility */
cursor: default;
pointer-events: none;
}
```
**Pattern: copy-to-clipboard inputs (readonly)**
`[readonly]` triggers the same `opacity: 0.45` rule. For API key / token display fields where the user must read and copy the value, restore opacity inline or via class:
```html
<input type="text" readonly class="lt-input" style="opacity:1;cursor:text">
```
**Pattern: description / content display areas**
Avoid rendering multi-line content in a `disabled` textarea — use a styled `<div>` instead. Apply `white-space: pre-wrap` on that div to preserve newlines and multiple spaces (required for ASCII art / diagrams to align correctly, since the body font is already monospace):
```css
.your-description-view {
white-space: pre-wrap;
word-break: break-word;
color: var(--text-primary);
}
```
Set `innerHTML = escHtml(rawText)` — no `<br>` replacement needed when `white-space: pre-wrap` is active.
---
## Changelog
### v1.2 (current)
+324 -19
View File
@@ -146,6 +146,7 @@
/* --- Typography --- */
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace;
--font-display: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace;
--font-crt: 'VT323', 'Courier New', monospace;
/* --- Spacing --- */
--space-xs: 0.25rem;
@@ -216,6 +217,8 @@ body {
min-height: 100vh;
overflow-x: hidden;
position: relative;
display: flex;
flex-direction: column;
}
a {
@@ -349,6 +352,16 @@ hr {
.lt-main {
padding-top: calc(var(--header-height) + var(--space-lg));
flex: 1;
width: 100%;
min-width: 0;
}
/* When both lt-main and lt-container are on the same element, the lt-container
shorthand `padding` overrides the lt-main `padding-top` in responsive breakpoints
(same cascade specificity, later rule wins). The combined selector has higher
specificity (0,2,0 vs 0,1,0) and always wins regardless of source order. */
.lt-main.lt-container {
padding-top: calc(var(--header-height) + var(--space-lg));
}
.lt-layout {
@@ -511,18 +524,22 @@ hr {
.lt-nav-dropdown-menu {
display: none;
position: absolute;
top: calc(100% + 4px);
top: 100%;
left: 0;
min-width: 180px;
background: rgba(6,12,20,0.98);
border: 1px solid var(--accent-cyan-border);
box-shadow: var(--box-glow-cyan), 0 16px 40px rgba(0,0,0,0.8);
z-index: var(--z-dropdown);
/* Invisible bridge above the menu so moving the cursor down from the
trigger into the menu doesn't cross a hover-dead gap */
padding-top: 6px;
margin-top: -2px;
}
.lt-nav-dropdown-menu::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
top: 6px; left: 0; right: 0;
height: 1px;
background: var(--accent-cyan);
box-shadow: var(--glow-cyan);
@@ -907,6 +924,19 @@ hr {
border-color: var(--border-dim);
}
/* Display-only fields — readable, non-editable, not "broken" */
.lt-display-field,
.lt-input.lt-display-field,
.lt-select.lt-display-field,
.lt-textarea.lt-display-field {
opacity: 1;
color: var(--text-secondary);
cursor: default;
pointer-events: none;
background: transparent;
border-color: var(--border-dim);
}
.lt-input::placeholder,
.lt-textarea::placeholder { color: var(--text-dim); }
@@ -1096,6 +1126,13 @@ select option:checked {
.lt-row-p3 { border-left: 2px solid var(--priority-3) !important; }
.lt-row-p4 { border-left: 2px solid var(--priority-4) !important; }
/* Row state aliases (unprefixed, compatible with monitoring apps) */
.lt-table tr.row-critical td:first-child, .lt-table tr.lt-row-critical td:first-child { border-left: 2px solid var(--accent-red); }
.lt-table tr.row-critical td { background: rgba(255,45,85,.04); }
.lt-table tr.row-warning td:first-child { border-left: 2px solid var(--accent-amber); }
.lt-table tr.row-warning td { background: rgba(255,107,0,.04); }
.lt-table tr.row-resolved td { opacity: 0.6; }
/* Compact data table */
.lt-data-table {
width: 100%;
@@ -1162,7 +1199,7 @@ select option:checked {
.lt-p5 { color: var(--priority-5); background: rgba(62,96,122,0.09); border-color: rgba(62,96,122,0.30); }
/* Chips */
.lt-chip {
.lt-chip, .chip {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.5rem;
@@ -1173,9 +1210,9 @@ select option:checked {
border: 1px solid currentColor;
}
.lt-chip-ok { color: var(--accent-green); background: var(--accent-green-dim); text-shadow: var(--glow-green); }
.lt-chip-warn { color: var(--accent-amber); background: var(--accent-amber-dim); }
.lt-chip-critical { color: var(--accent-red); background: var(--accent-red-dim); text-shadow: var(--glow-red); }
.lt-chip-ok, .chip-ok { color: var(--accent-green); background: var(--accent-green-dim); text-shadow: var(--glow-green); }
.lt-chip-warn, .chip-warning { color: var(--accent-amber); background: var(--accent-amber-dim); }
.lt-chip-critical, .chip-critical { color: var(--accent-red); background: var(--accent-red-dim); text-shadow: var(--glow-red); }
.lt-chip-info { color: var(--accent-cyan); background: var(--accent-cyan-dim); text-shadow: var(--glow-cyan); }
/* Generic badges */
@@ -1190,6 +1227,7 @@ select option:checked {
.lt-badge-green { color: var(--accent-green); }
.lt-badge-amber { color: var(--accent-amber); }
.lt-badge-red { color: var(--accent-red); }
.lt-badge-sm { font-size: 0.5rem; padding: 0.05rem 0.3rem; }
/* Status + priority badge variants (dark-mode base) */
.lt-badge-open { color: var(--accent-green); background: rgba(0,255,136,0.08); border-color: rgba(0,255,136,0.35); text-shadow: var(--glow-green); }
@@ -1210,10 +1248,11 @@ select option:checked {
border-radius: 50%;
flex-shrink: 0;
}
.lt-dot-up { background: var(--accent-green); box-shadow: 0 0 6px var(--accent-green), 0 0 14px var(--accent-green); animation: pulse-green 2.2s ease-in-out infinite; will-change: box-shadow; }
.lt-dot-down { background: var(--accent-red); box-shadow: 0 0 6px var(--accent-red), 0 0 12px var(--accent-red); }
.lt-dot-warn { background: var(--accent-amber); box-shadow: 0 0 6px var(--accent-amber), 0 0 12px var(--accent-amber); animation: pulse-amber 2.2s ease-in-out infinite; will-change: box-shadow; }
.lt-dot-idle { background: var(--text-muted); box-shadow: none; }
.lt-dot-up, .dot-up { background: var(--accent-green); box-shadow: 0 0 6px var(--accent-green), 0 0 14px var(--accent-green); animation: pulse-green 2.2s ease-in-out infinite; will-change: box-shadow; }
.lt-dot-down, .dot-down { background: var(--accent-red); box-shadow: 0 0 6px var(--accent-red), 0 0 12px var(--accent-red); }
.lt-dot-warn, .dot-degraded { background: var(--accent-amber); box-shadow: 0 0 6px var(--accent-amber), 0 0 12px var(--accent-amber); animation: pulse-amber 2.2s ease-in-out infinite; will-change: box-shadow; }
.lt-dot-idle, .dot-unknown,
.dot-initial_down { background: var(--text-muted); box-shadow: none; }
/* ----------------------------------------------------------------
@@ -1292,6 +1331,20 @@ select option:checked {
.lt-modal-close:active { color: var(--accent-red); opacity: 0.7; }
.lt-modal-close:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; border-radius: 2px; }
/* Modal size modifiers */
.lt-modal-xs { width: min(280px, 92vw); }
.lt-modal-sm { width: min(360px, 92vw); }
/* Modal header danger variant */
.lt-modal-header--danger {
background: rgba(255, 77, 77, 0.08);
border-bottom-color: var(--accent-red);
}
.lt-modal-header--danger .lt-modal-title {
color: var(--accent-red);
text-shadow: var(--glow-red);
}
.lt-modal-body {
padding: var(--space-lg);
overflow-y: auto;
@@ -2028,7 +2081,7 @@ select option:checked {
/* ── SM — phones 480767px ── */
@media (max-width: 767px) {
:root { --header-height: 50px; }
.lt-main { padding-top: calc(50px + var(--space-md)); }
.lt-main, .lt-main.lt-container { padding-top: calc(50px + var(--space-md)); }
.lt-container { padding: var(--space-md); }
.lt-header { padding: 0 var(--space-md); }
@@ -2128,7 +2181,7 @@ select option:checked {
/* ── XS — tiny phones ≤ 479px ── */
@media (max-width: 479px) {
:root { --header-height: 46px; }
.lt-main { padding-top: calc(46px + var(--space-sm)); }
.lt-main, .lt-main.lt-container { padding-top: calc(46px + var(--space-sm)); }
.lt-container { padding: var(--space-sm); }
.lt-stats-grid { grid-template-columns: 1fr 1fr; gap: var(--space-xs); }
@@ -2412,7 +2465,7 @@ select option:checked {
}
.lt-progress-bar {
height: 100%;
background: var(--accent-orange);
background: linear-gradient(90deg, var(--accent-orange), #ff8c2b);
box-shadow: var(--glow-orange);
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
@@ -2425,9 +2478,9 @@ select option:checked {
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4));
}
.lt-progress--cyan .lt-progress-bar { background: var(--accent-cyan); box-shadow: var(--glow-cyan); }
.lt-progress--green .lt-progress-bar { background: var(--accent-green); box-shadow: var(--glow-green); }
.lt-progress--red .lt-progress-bar { background: var(--accent-red); box-shadow: var(--glow-red); }
.lt-progress--cyan .lt-progress-bar { background: linear-gradient(90deg, var(--accent-cyan), #33dfff); box-shadow: var(--glow-cyan); }
.lt-progress--green .lt-progress-bar { background: linear-gradient(90deg, var(--accent-green), #33ffaa); box-shadow: var(--glow-green); }
.lt-progress--red .lt-progress-bar { background: linear-gradient(90deg, var(--accent-red), #ff4466); box-shadow: var(--glow-red); }
.lt-progress--striped .lt-progress-bar {
background-image: repeating-linear-gradient(
45deg, transparent, transparent 4px,
@@ -3165,6 +3218,30 @@ input[type="range"].lt-range::-moz-range-thumb {
.lt-kv-val--green { color: var(--accent-green); }
.lt-kv-val--red { color: var(--accent-red); }
/* lt-kv-row / lt-kv-label / lt-kv-value — alternate KV row pattern using
CSS `display: contents` so children become direct grid items of lt-kv-grid */
.lt-kv-row {
display: contents;
}
.lt-kv-label {
padding: var(--space-xs) var(--space-md) var(--space-xs) 0;
color: var(--text-dim);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
border-bottom: 1px solid var(--border-dim);
align-self: center;
}
.lt-kv-value {
padding: var(--space-xs) 0 var(--space-xs) var(--space-md);
color: var(--text-primary);
border-bottom: 1px solid var(--border-dim);
min-width: 0;
overflow-wrap: break-word;
align-self: center;
}
/* ----------------------------------------------------------------
43. HERO / BANNER SECTION
@@ -3759,6 +3836,21 @@ html[data-theme="light"] .lt-drawer-right-header { background: var(--bg-seconda
html[data-theme="light"] .lt-drawer-right-footer { background: var(--bg-secondary); border-top-color: var(--border-color); }
html[data-theme="light"] .lt-drawer-right-overlay { background: rgba(30,40,70,0.35); }
/* — Nav dropdown menu — */
html[data-theme="light"] .lt-nav-dropdown-menu {
background: var(--bg-card);
border-color: var(--border-color);
box-shadow: 0 6px 20px rgba(0,0,0,0.12);
}
html[data-theme="light"] .lt-nav-dropdown-menu li a {
color: var(--text-secondary);
border-bottom-color: var(--border-dim);
}
html[data-theme="light"] .lt-nav-dropdown-menu li a:hover {
color: var(--accent-orange);
background: var(--accent-orange-dim);
}
/* — Dropdowns & notification panel — */
html[data-theme="light"] .lt-dropdown-panel {
background: var(--bg-card);
@@ -4388,7 +4480,83 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
/* ----------------------------------------------------------------
61. TIMELINE / ACTIVITY FEED
61. SLA BANNER
----------------------------------------------------------------
lt-sla-p1 — pulsing red banner for critical SLA breach
lt-sla-p2 — static amber banner for high-priority SLA warning
---------------------------------------------------------------- */
.lt-sla-p1,
.lt-sla-p2 {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 1rem;
border: 1px solid;
font-family: var(--font-mono);
}
.lt-sla-p1 {
border-color: rgba(255,45,85,0.4);
background: rgba(255,45,85,0.08);
animation: lt-sla-pulse 2s infinite;
}
.lt-sla-p2 {
border-color: rgba(255,179,0,0.4);
background: rgba(255,179,0,0.08);
}
@keyframes lt-sla-pulse {
0%, 100% { box-shadow: 0 0 8px rgba(255,45,85,0.20); }
50% { box-shadow: 0 0 20px rgba(255,45,85,0.45); }
}
.lt-sla-icon { font-size: 1rem; flex-shrink: 0; }
.lt-sla-info { flex: 1; min-width: 0; }
.lt-sla-title {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
margin-bottom: 4px;
}
.lt-sla-p1 .lt-sla-title { color: var(--accent-red); text-shadow: var(--glow-red); }
.lt-sla-p2 .lt-sla-title { color: var(--accent-amber); text-shadow: var(--glow-amber); }
.lt-sla-bar {
height: 5px;
background: rgba(255,255,255,0.08);
position: relative;
overflow: hidden;
}
.lt-sla-fill {
height: 100%;
width: 0%;
transition: width 0.4s ease;
}
.lt-sla-p1 .lt-sla-fill { background: linear-gradient(90deg, var(--accent-red), var(--accent-orange)); box-shadow: 0 0 8px rgba(255,45,85,0.6); }
.lt-sla-p2 .lt-sla-fill { background: linear-gradient(90deg, var(--accent-amber), #ffd740); box-shadow: 0 0 8px rgba(255,179,0,0.6); }
.lt-sla-meta {
font-size: 0.60rem;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.10em;
flex-shrink: 0;
}
.lt-sla-dismiss {
font-size: 0.70rem;
color: var(--text-dim);
cursor: pointer;
background: none;
border: none;
flex-shrink: 0;
padding: 0 0.25rem;
font-family: var(--font-mono);
transition: color 0.15s ease;
}
.lt-sla-dismiss:hover { color: var(--text-secondary); }
.lt-sla-dismiss:focus-visible { outline: 1px dashed var(--accent-cyan); outline-offset: 2px; }
html[data-theme="light"] .lt-sla-p1 { background: rgba(180,30,50,0.06); border-color: rgba(180,30,50,0.35); }
html[data-theme="light"] .lt-sla-p2 { background: rgba(138,90,0,0.06); border-color: rgba(138,90,0,0.35); }
/* ----------------------------------------------------------------
62. TIMELINE / ACTIVITY FEED
---------------------------------------------------------------- */
.lt-timeline {
display: flex;
@@ -4451,8 +4619,19 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
overflow: hidden;
flex-shrink: 0;
user-select: none;
position: relative; /* needed so img can overlay initials absolutely */
}
.lt-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* Image overlays initials — hidden by JS (.lt-avatar-img-err) when broken */
.lt-avatar img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* When the image fails, JS adds .lt-avatar-img-err to hide it, revealing initials */
.lt-avatar img.lt-avatar-img-err { display: none; }
/* Sizes */
.lt-avatar--xs { width: 1.5rem; height: 1.5rem; font-size: 0.55rem; }
.lt-avatar--sm { width: 2rem; height: 2rem; font-size: 0.65rem; }
@@ -5066,7 +5245,23 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
.lt-markdown a:hover { text-decoration: underline; }
.lt-markdown a:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; }
.lt-markdown strong { color: var(--text-primary); }
.lt-markdown img { max-width: 100%; border: 1px solid var(--border-dim); }
.lt-markdown img, .md-image { max-width: 100%; height: auto; border: 1px solid var(--border-dim); border-radius: 2px; display: block; margin: 0.5rem 0; }
.lt-markdown ul { list-style: disc; }
.lt-markdown ol { list-style: decimal; }
.lt-markdown ul li::marker { color: var(--accent-cyan); }
.lt-markdown ol li::marker { color: var(--accent-orange); }
.lt-markdown mark { background: var(--accent-yellow-dim, #2a2500); color: var(--accent-yellow, #e6c619); padding: 0 3px; border-radius: 2px; }
.lt-markdown del { color: var(--text-muted); text-decoration: line-through; }
.lt-markdown sub, .lt-markdown sup { font-size: 0.7em; line-height: 0; }
.lt-markdown .task-item { list-style: none; margin-left: -1.2em; }
.lt-markdown .task-cb { margin-right: 0.35em; font-size: 1em; }
.lt-markdown .task-done { color: var(--text-muted); text-decoration: line-through; }
.lt-markdown .task-todo { color: var(--text-secondary); }
.lt-markdown .fn-ref a { color: var(--accent-cyan); font-size: 0.7em; text-decoration: none; }
.lt-markdown .fn-hr { margin: 1rem 0 0.5rem; }
.lt-markdown .fn-list { font-size: 0.75rem; color: var(--text-muted); list-style: decimal; padding-left: 1.25rem; margin: 0; }
.lt-markdown .fn-item { margin-bottom: 0.2rem; }
.lt-markdown .fn-back { color: var(--accent-cyan); text-decoration: none; font-size: 0.85em; }
.lt-markdown table { width: 100%; border-collapse: collapse; font-size: 0.78rem; margin: 0.75rem 0; }
.lt-markdown th { background: var(--bg-secondary); color: var(--accent-cyan); padding: 0.4rem 0.6rem; border: 1px solid var(--border-dim); text-align: left; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; }
.lt-markdown td { padding: 0.35rem 0.6rem; border: 1px solid var(--border-dim); color: var(--text-secondary); }
@@ -5488,3 +5683,113 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
@media (max-width: 479px) {
.lt-footer { flex-direction: column; align-items: flex-start; gap: 0.25rem; }
}
/* Footer keyboard hint strip — a row of clickable key+label hints */
.lt-footer-hints { display: flex; align-items: center; flex-wrap: wrap; gap: 0.25rem; }
.lt-footer-hint {
all: unset;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.25rem;
color: var(--text-muted);
font-size: 0.7rem;
font-family: var(--font-mono);
white-space: nowrap;
}
.lt-footer-hint:hover { color: var(--accent-cyan); }
.lt-footer-hint:focus-visible { outline: 1px dashed var(--accent-cyan); outline-offset: 2px; }
.lt-footer-key {
color: var(--accent-cyan);
opacity: 0.8;
}
.lt-footer-sep {
color: var(--border-dim);
user-select: none;
}
/* ================================================================
BLINKING CURSOR
<h1 class="lt-cursor">SYSTEM STATUS</h1>
<span class="lt-cursor lt-cursor--cyan">SCANNING</span>
================================================================ */
.lt-cursor::after {
content: '▊';
animation: lt-blink 1s step-end infinite;
color: var(--accent-green);
margin-left: 2px;
}
.lt-cursor--cyan::after { color: var(--accent-cyan); }
.lt-cursor--orange::after { color: var(--accent-orange); }
.lt-cursor--red::after { color: var(--accent-red); }
@keyframes lt-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* ================================================================
CRT SCANLINE OVERLAY
Add lt-scanlines to <body> or any container to enable.
Automatically suppressed in light theme.
================================================================ */
.lt-scanlines::after {
content: '';
position: fixed;
inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 0, 0, 0.04) 2px,
rgba(0, 0, 0, 0.04) 4px
);
pointer-events: none;
z-index: 9998;
}
html[data-theme="light"] .lt-scanlines::after { display: none; }
/* ================================================================
RADAR SWEEP LOADING INDICATOR
<div class="lt-radar"></div>
Drop-in replacement for lt-spinner where a radar aesthetic fits.
================================================================ */
.lt-radar {
display: inline-block;
width: 48px; height: 48px;
border-radius: 50%;
border: 1px solid var(--accent-cyan);
position: relative;
overflow: hidden;
flex-shrink: 0;
}
.lt-radar::before {
content: '';
position: absolute;
inset: 0;
background: conic-gradient(
from 0deg,
transparent 70%,
rgba(0, 212, 255, 0.35) 100%
);
animation: lt-radar-sweep 2s linear infinite;
transform-origin: center;
}
.lt-radar::after {
content: '';
position: absolute;
inset: 50% 0 0 50%;
width: 1px; height: 50%;
background: var(--accent-cyan);
transform-origin: top left;
animation: lt-radar-sweep 2s linear infinite;
opacity: 0.6;
}
.lt-radar--sm { width: 28px; height: 28px; }
.lt-radar--lg { width: 72px; height: 72px; }
.lt-radar--green { border-color: var(--accent-green); }
.lt-radar--green::before { background: conic-gradient(from 0deg, transparent 70%, rgba(0,255,136,0.35) 100%); }
.lt-radar--green::after { background: var(--accent-green); }
@keyframes lt-radar-sweep { to { transform: rotate(360deg); } }
+55 -2
View File
@@ -29,10 +29,10 @@
All <script> tags need: nonce="NONCE_PLACEHOLDER"
========================================================= -->
<!-- Monospace font: JetBrains Mono -->
<!-- Fonts: JetBrains Mono (UI) + VT323 (CRT display accent) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&family=VT323&display=swap" rel="stylesheet">
<!-- Hacker template design system CSS -->
<link rel="stylesheet" href="/web_template/base.css">
@@ -794,6 +794,7 @@
<label class="lt-label">Loading state (skeleton)</label>
<div class="lt-skeleton lt-p-md" style="height:40px"></div>
</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>
@@ -802,6 +803,13 @@
<label class="lt-label">Loading indicator</label>
<div class="lt-loading" style="padding:1rem"></div>
</div>
<div class="lt-form-group">
<label class="lt-label">Display-only fields <code style="font-size:0.7rem">.lt-display-field</code></label>
<select class="lt-select lt-display-field" style="margin-bottom:0.5rem">
<option>P3 — Medium</option>
</select>
<input type="text" class="lt-input lt-display-field" value="Read-only value">
</div>
</div>
</div>
</div>
@@ -924,6 +932,29 @@
</div>
</div>
<!-- SLA BANNERS -->
<div class="lt-section-header">SLA Banners</div>
<div class="lt-section-body" style="display:flex;flex-direction:column;gap:var(--space-sm)">
<div class="lt-sla-p1" role="alert" data-sla-id="demo-p1">
<span class="lt-sla-icon" aria-hidden="true">[ ! ]</span>
<div class="lt-sla-info">
<div class="lt-sla-title">P1 Critical — SLA: 6h 42m elapsed of 8h limit</div>
<div class="lt-sla-bar"><div class="lt-sla-fill" style="width:84%"></div></div>
</div>
<div class="lt-sla-meta">Storage array link-down · #123456789</div>
<button type="button" class="lt-sla-dismiss" aria-label="Dismiss"></button>
</div>
<div class="lt-sla-p2" role="alert" data-sla-id="demo-p2">
<span class="lt-sla-icon" aria-hidden="true">[ ~ ]</span>
<div class="lt-sla-info">
<div class="lt-sla-title">P2 High — SLA: 9h 37m elapsed of 24h limit</div>
<div class="lt-sla-bar"><div class="lt-sla-fill" style="width:40%"></div></div>
</div>
<div class="lt-sla-meta">Switch port flapping · #987654321</div>
<button type="button" class="lt-sla-dismiss" aria-label="Dismiss"></button>
</div>
</div>
<!-- TOGGLES, RANGE, TAGS -->
<div class="lt-section-header">Toggles / Range / Tags</div>
<div class="lt-section-body" style="display:flex;flex-direction:column;gap:var(--space-lg)">
@@ -959,6 +990,13 @@
<span class="lt-tag lt-tag--purple">ADMIN</span>
<span class="lt-tag">UNTAGGED</span>
</div>
<div style="margin-top:var(--space-md);display:flex;flex-wrap:wrap;gap:var(--space-md);align-items:center">
<span class="lt-cursor" style="font-size:1.1rem;font-family:var(--font-mono)">SYSTEM STATUS</span>
<span class="lt-cursor lt-cursor--cyan" style="font-size:1.1rem;font-family:var(--font-mono)">SCANNING</span>
<span class="lt-cursor lt-cursor--orange" style="font-size:1.1rem;font-family:var(--font-mono)">AWAITING INPUT</span>
<span style="font-family:var(--font-crt);font-size:2rem;color:var(--accent-green)">42 NODES</span>
<span class="lt-tag lt-tag--red" style="font-family:var(--font-crt);font-size:1rem;letter-spacing:0.05em">CRITICAL</span>
</div>
</div>
<!-- CODE BLOCK -->
@@ -1055,6 +1093,9 @@
<div class="lt-bar-loader"><span></span><span></span><span></span><span></span></div>
<div class="lt-spinner"></div>
<div class="lt-spinner lt-spinner--cyan lt-spinner--sm"></div>
<div class="lt-radar lt-radar--sm"></div>
<div class="lt-radar"></div>
<div class="lt-radar lt-radar--green"></div>
<div class="lt-pulse-dot"></div>
</div>
</div>
@@ -1960,6 +2001,18 @@ Storage array link-down on `compute-storage-01`.
});
});
// SLA dismiss buttons — hide and persist per session
document.querySelectorAll('.lt-sla-dismiss').forEach(btn => {
const banner = btn.closest('.lt-sla-p1, .lt-sla-p2');
if (!banner) return;
const id = banner.dataset.slaId;
try { if (id && sessionStorage.getItem('lt_sla_dismissed_' + id)) banner.hidden = true; } catch (_) {}
btn.addEventListener('click', () => {
banner.hidden = true;
try { if (id) sessionStorage.setItem('lt_sla_dismissed_' + id, '1'); } catch (_) {}
});
});
// Footer year
const footerYear = document.getElementById('footer-year');
if (footerYear) footerYear.textContent = new Date().getFullYear();
+4 -1
View File
@@ -312,10 +312,13 @@
lt.boot.run('APP NAME')
lt.boot.run('APP NAME', true) // force replay
---------------------------------------------------------------- */
let _bootFired = false; // in-memory guard: survives within a JS context, resets on true page reload
function runBoot(appName, force) {
if (!force && _bootFired) return; // Fastest guard — blocks any same-page double-call
const storageKey = 'lt_booted_' + (appName || 'app');
if (!force && sessionStorage.getItem(storageKey)) return;
sessionStorage.setItem(storageKey, '1'); // Claim the run immediately to block double-init
_bootFired = true;
sessionStorage.setItem(storageKey, '1');
const overlay = document.getElementById('lt-boot');
const pre = document.getElementById('lt-boot-text');
if (!overlay || !pre) return;
+146
View File
@@ -0,0 +1,146 @@
<%
LOTUSGUILD TERMINAL DESIGN SYSTEM — Node.js / Express EJS Base Layout
Extend this in every page template via res.render('page', { ... }).
Required Express setup (server.js / app.js):
const { requireAuth, cspNonce, injectLocals } = require('./middleware');
app.use(cspNonce);
app.use(requireAuth);
app.use(injectLocals);
app.set('view engine', 'ejs');
Locals injected automatically by middleware.js:
user { username, name, email, groups, isAdmin }
nonce CSP nonce string
appName process.env.APP_NAME
appSubtitle process.env.APP_SUBTITLE
Locals to set per-route (or via a second res.locals middleware):
pageTitle string — page <title> suffix
activeNav string — must match a navLinks[].key
navLinks array — navigation items:
[{ href: '/path', key: 'mykey', label: 'My Page' }, ...]
Dropdown:
{ label: 'Admin', key: 'admin', adminOnly: true, children: [
{ href: '/admin/users', label: 'Users' }
]}
pageStyles array — optional extra CSS hrefs
pageScripts array — optional extra <script src> paths
%>
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#030508">
<meta name="robots" content="noindex, nofollow">
<title><%= pageTitle ? pageTitle + ' — ' : '' %><%= appName || 'LotusGuild' %></title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&family=VT323&display=swap" rel="stylesheet">
<!-- Design system -->
<link rel="stylesheet" href="/web_template/base.css">
<!-- App-specific CSS (extends base, never overrides variables without good reason) -->
<link rel="stylesheet" href="/assets/app.css">
<% if (typeof pageStyles !== 'undefined') { pageStyles.forEach(href => { %>
<link rel="stylesheet" href="<%- href %>">
<% }); } %>
<link rel="icon" href="/assets/favicon.png" type="image/png">
</head>
<body>
<!-- Boot overlay -->
<div id="lt-boot" class="lt-boot-overlay"
data-app-name="<%= (appName || 'APP').toUpperCase() %>"
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">
<%= (appName || 'APP').toUpperCase() %>
</a>
<span class="lt-brand-subtitle"><%= appSubtitle || 'LotusGuild Infrastructure' %></span>
</div>
<nav class="lt-nav" aria-label="Main navigation">
<% (navLinks || []).forEach(link => {
if (link.adminOnly && !user.isAdmin) return;
if (link.children) { %>
<div class="lt-nav-dropdown">
<a href="#" class="lt-nav-link <%= (activeNav || '').startsWith(link.key) ? 'active' : '' %>">
<%= link.label %> ▾
</a>
<ul class="lt-nav-dropdown-menu">
<% link.children.forEach(child => { %>
<li><a href="<%= child.href %>"><%= child.label %></a></li>
<% }); %>
</ul>
</div>
<% } else { %>
<a href="<%= link.href %>"
class="lt-nav-link <%= activeNav === link.key ? 'active' : '' %>">
<%= link.label %>
</a>
<% } }); %>
</nav>
</div>
<div class="lt-header-right">
<% if (user && (user.name || user.username)) { %>
<span class="lt-header-user"><%= user.name || user.username %></span>
<% } %>
<% if (user && user.isAdmin) { %>
<span class="lt-badge-admin">admin</span>
<% } %>
</div>
</header>
<!-- =========================================================
MAIN CONTENT — provided by the including view via <%- body %>
or by structuring routes to render with this as a wrapper.
========================================================= -->
<main class="lt-main lt-container">
<%- body %>
</main>
<!-- =========================================================
SCRIPTS — all tags carry the CSP nonce
========================================================= -->
<!-- Runtime globals -->
<script nonce="<%= nonce %>">
window.CSRF_TOKEN = <%= JSON.stringify(csrfToken || '') %>;
window.CURRENT_USER = {
username: <%= JSON.stringify(user.username || '') %>,
name: <%= JSON.stringify(user.name || '') %>,
groups: <%= JSON.stringify(user.groups || []) %>,
isAdmin: <%= user.isAdmin ? 'true' : 'false' %>,
};
// App-specific config: set window.APP_CONFIG in your route's inline script,
// not here. This file is shared across all apps.
</script>
<!-- Design system -->
<script nonce="<%= nonce %>" src="/web_template/base.js"></script>
<!-- App JS -->
<script nonce="<%= nonce %>" src="/assets/app.js"></script>
<% if (typeof pageScripts !== 'undefined') { pageScripts.forEach(src => { %>
<script nonce="<%= nonce %>" src="<%- src %>"></script>
<% }); } %>
</body>
</html>
+1140
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
{
"devDependencies": {
"eslint": "^8.57.1"
}
}
+30 -15
View File
@@ -17,8 +17,14 @@
* $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.)
* $activeNav string Which nav key is active — must match a $navLinks entry
* $config array From config/config.php
* $navLinks array Navigation items:
* [['href' => '/path', 'key' => 'mykey', 'label' => 'My Page'], ...]
* Nested (dropdown):
* ['label' => 'Admin', 'key' => 'admin', 'adminOnly' => true, 'children' => [
* ['href' => '/admin/users', 'label' => 'Users'],
* ]]
*/
// Defensive defaults
@@ -27,6 +33,7 @@ $currentUser = $currentUser ?? [];
$pageTitle = $pageTitle ?? 'Dashboard';
$activeNav = $activeNav ?? '';
$config = $config ?? [];
$navLinks = $navLinks ?? [];
$isAdmin = $currentUser['is_admin'] ?? false;
?>
<!DOCTYPE html>
@@ -40,7 +47,7 @@ $isAdmin = $currentUser['is_admin'] ?? false;
<!-- 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="stylesheet" href="/assets/css/app.css?v=<?php echo $config['CSS_VERSION'] ?? '1'; ?>">
<link rel="icon" href="/assets/images/favicon.png" type="image/png">
</head>
@@ -67,25 +74,31 @@ $isAdmin = $currentUser['is_admin'] ?? false;
</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 foreach ($navLinks as $link): ?>
<?php
$skipAdminOnly = !empty($link['adminOnly']) && !$isAdmin;
if ($skipAdminOnly) continue;
?>
<?php if ($isAdmin): ?>
<?php if (!empty($link['children'])): ?>
<?php $parentActive = str_starts_with($activeNav, $link['key']); ?>
<div class="lt-nav-dropdown">
<a href="#" class="lt-nav-link <?php echo str_starts_with($activeNav, 'admin') ? 'active' : ''; ?>">
Admin
<a href="#" class="lt-nav-link <?php echo $parentActive ? 'active' : ''; ?>">
<?php echo htmlspecialchars($link['label']); ?>
</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>
<?php foreach ($link['children'] as $child): ?>
<li><a href="<?php echo htmlspecialchars($child['href']); ?>"><?php echo htmlspecialchars($child['label']); ?></a></li>
<?php endforeach; ?>
</ul>
</div>
<?php else: ?>
<a href="<?php echo htmlspecialchars($link['href']); ?>"
class="lt-nav-link <?php echo $activeNav === $link['key'] ? 'active' : ''; ?>">
<?php echo htmlspecialchars($link['label']); ?>
</a>
<?php endif; ?>
<?php endforeach; ?>
</nav>
</div>
@@ -130,6 +143,8 @@ $isAdmin = $currentUser['is_admin'] ?? false;
username: <?php echo json_encode($currentUser['username'] ?? ''); ?>,
isAdmin: <?php echo $isAdmin ? 'true' : 'false'; ?>,
};
// App-specific config: set window.APP_CONFIG in your app's own <script> block,
// not here. This file is shared across all apps.
</script>
<!-- Unified design system JS -->
@@ -137,7 +152,7 @@ $isAdmin = $currentUser['is_admin'] ?? false;
<!-- App-specific JS (cache-busted) -->
<script nonce="<?php echo $nonce; ?>"
src="/assets/js/app.js?v=<?php echo $config['JS_VERSION'] ?? '20260314'; ?>">
src="/assets/js/app.js?v=<?php echo $config['JS_VERSION'] ?? '1'; ?>">
</script>
<!-- Per-page inline JS goes here in the including view, e.g.: -->
+35 -21
View File
@@ -17,7 +17,8 @@
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.
- Pass `config` dict with APP_NAME, APP_SUBTITLE, etc.
- Pass `nav_links` list of dicts defining navigation
Context processor example:
@app.context_processor
@@ -25,6 +26,16 @@
import base64, os
nonce = base64.b64encode(os.urandom(16)).decode()
return dict(nonce=nonce, user=_get_user(), config=_config())
nav_links format (pass from route or context processor):
nav_links = [
{'href': url_for('index'), 'key': 'dashboard', 'label': 'Dashboard'},
{'href': url_for('settings'), 'key': 'settings', 'label': 'Settings'},
# Admin-only dropdown:
{'label': 'Admin', 'key': 'admin', 'admin_only': True, 'children': [
{'href': url_for('admin_users'), 'label': 'Users'},
]},
]
#}
<!DOCTYPE html>
<html lang="en">
@@ -64,24 +75,28 @@
</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
{% for link in nav_links | default([]) %}
{% if not link.get('admin_only') or 'admin' in user.groups %}
{% if link.get('children') %}
<div class="lt-nav-dropdown">
<a href="#" class="lt-nav-link {% if active.startswith(link.key) %}active{% endif %}">
{{ link.label }} ▾
</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
<ul class="lt-nav-dropdown-menu">
{% for child in link.children %}
<li><a href="{{ child.href }}">{{ child.label }}</a></li>
{% endfor %}
</ul>
</div>
{% else %}
<a href="{{ link.href }}"
class="lt-nav-link {% if active == link.key %}active{% endif %}">
{{ link.label }}
</a>
{% endif %}
{% endif %}
{% endfor %}
</nav>
</div>
@@ -107,17 +122,16 @@
All <script> tags MUST carry the nonce attribute for CSP.
========================================================= -->
<!-- Runtime config (no CSRF needed for Gandalf — SameSite=Strict) -->
<!-- Runtime config injected by the server -->
<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 }},
};
// App-specific config: set window.APP_CONFIG in your app's own template block,
// not here. This file is shared across all apps.
</script>
<!-- Unified design system JS -->
@@ -136,6 +150,6 @@
{% block active_nav %}dashboard{% endblock %}
Values: dashboard | links | inspector | suppressions
Value must match a 'key' in your nav_links list.
--------------------------------------------------------------- #}
{% block active_nav %}{% endblock %}