Pulse's UI was a single hand-rolled static page — public/index.html, 3161 lines, ~830 lines of
inline <style>, ~2000 lines of inline JS and 58 inline onclick/onchange handlers — served by express.static('public'), with public/base.js a git symlink into a sibling web_template
checkout that only happens to exist on CT122.
This PR replaces it with six server-rendered EJS pages inside the shared LotusGuild chrome, a
vendored copy of the design system, a strict nonce-based CSP, and a small shared frontend runtime
(window.Pulse) that page modules plug into. No API behaviour changes: /api/* responses are
byte-identical to main (verified, see below), and the commandHistory / pulse_executionView
localStorage keys are preserved.
Feature parity was mandatory and is complete; the redesign also picks up a set of behavioural
improvements that fall out of the design system (see Behavioural notes).
What changed by area
Server (server.js, lib/)
helmet with a strict nonce CSP: default-src 'self', script-src 'self' 'nonce-…', script-src-attr 'none', object-src 'none', frame-ancestors 'none'; PULSE_CSP_REPORT_ONLY=1 switches to report-only with report-uri /csp-report.
Static mounts moved to /web_template and /assets (the root public/ mount is gone), with maxAge: 1h and ?v=<pkg version>-<web_template sha> cache busting.
Six page routes driven by a PAGES table in lib/nav.js; lib/render.js does two-step EJS
rendering (page view → body → layout.ejs) and renders themed HTML error pages for 401/403/404.
lib/pageauth.js: the users upsert extracted out of authenticateSSO and reused by page auth,
which returns themed HTML (not JSON) for 401/403. /api/* auth is untouched.
PULSE_DEV_READONLY=1 (alias DISABLE_BACKGROUND_JOBS=1) disables every background writer —
stale-execution recovery, execution cleanup, the scheduled-command processor, the stale-worker
sweep — so a dev instance can safely point at the production database. Each skip is logged.
package.json: "start": "node server.js", "main" corrected to server.js; ejs 3.1.10 and helmet 8.1.0 pinned in dependencies; lockfile committed.
Design system (public/web_template/)
base.css, base.js and a VERSION file are now vendored as real files (no symlink, no
runtime dependency on a sibling checkout). scripts/sync-web-template.sh <checkout> refreshes
them and rewrites VERSION. .eslintignore excludes the vendored tree.
The vendored layout.ejs fixes upstream's en-dash comment delimiters (which make the upstream
file unrenderable) and adds a skip link, mobile drawer, theme button, WS status dot,
"Refreshed: HH:MM:SS" stamp, command palette and keyboard-shortcut modal.
Shared frontend (public/assets/app.js, app.css)
window.Pulse: action registry + delegated data-action / data-change-action / data-input-action / data-submit-action listeners, event bus, Pulse.confirm / Pulse.alertModal (themed, promise-based), formatters, escaping, a 401→reload fetch wrapper,
the single WebSocket connection, and the singlelt.autoRefresh registration (30 s).
Page modules register with Pulse.registerPage({ name, init, refresh, onEvent }) and never own
timers, listeners or the WebSocket.
Dashboard, Workers, Workflows, Executions, Quick Command, Scheduler — each an EJS view plus a
page module confined to its own action prefix (dash:, wk:, wf:, ex:, qc:, sc:) and
DOM id prefix. Every dynamic string goes through Pulse.esc.
Integration/cleanup (this work package)
lib/render.js error page used .lt-alert-danger, which does not exist in base.css → fixed to .lt-alert--error (tree-wide grep for single-dash alert classes is now clean).
.lt-modal-lg was used by three pages but defined by none (Executions faked it with an
id-scoped width rule). Added one shared rule in app.css, mirroring base.css's sizing
conventions and re-capping at its two breakpoints; removed the id-scoped duplicate.
.lt-field-error was defined page-locally in workflows.css; moved it plus an .is-invalid
input state into app.css so every page shares them (wf-input-invalid → is-invalid).
views/pages/quick.ejs used the non-existent .lt-field-hint → .lt-form-hint.
Dashboard load failures now surface a toast (once per outage) instead of only a console error.
Deleted public/index.html and the public/base.js symlink.
README: rewritten Styling & Layout section (vendored design system, sync script, VERSION), a new
Web UI section (routes, the Pulse.registerPage contract, the CSP rule for contributors), the
new env vars, and a "Local development against a real database" note.
Screenshots
None — server-rendered pages, verified via curl/jsdom.
Verification performed
Local instance against the production MariaDB with PULSE_DEV_READONLY=1 PORT=8099 HOST=127.0.0.1.
Startup log confirms all four background writers skipped.
Route matrix — six routes × {admin, employee} = 12 requests, all: 200, exactly 4 × nonce=", exactly one .lt-nav-link active and it points at the current
route, and a content-security-policy header containing script-src-attr 'none'.
/
/workers
/workflows
/executions
/quick
/scheduler
admin
200 ✓
200 ✓
200 ✓
200 ✓
200 ✓
200 ✓
employee
200 ✓
200 ✓
200 ✓
200 ✓
200 ✓
200 ✓
Also: no auth headers → 401 HTML; Remote-Groups: guest → 403 HTML (themed page, now
rendering the corrected .lt-alert--error); /index.html → 404; /base.js → 404; /api/nope → 404 JSON ({"error":"Not found"}); all 18 /assets/** and /web_template/*
URLs → 200. With PULSE_CSP_REPORT_ONLY=1: Content-Security-Policy-Report-Only present with report-uri /csp-report, and no enforcing Content-Security-Policy header.
API byte-compare vs main — main checked out in a throwaway worktree with the same .env,
started on :8098 and stopped within seconds (before its 60 s scheduler/stale-worker intervals
could fire), responses captured from both ports back-to-back with identical headers:
Endpoint
Result
/api/workers
identical (371 B)
/api/workflows
identical (50 936 B)
/api/executions?limit=5
identical (1 180 B)
/api/scheduled-commands
identical (2 B)
/api/user
identical (103 B)
Byte-identical, not merely equivalent — no timestamp drift to explain away.
Behavioural QA (jsdom) — each page's live-rendered HTML plus base.js + app.js + its page
module, with fetch stubbed from JSON captured off the running instance:
Suite
Result
Integration (6 pages + shell)
40/40
Dashboard + Workers (WP-C)
ALL PASS
Workflows (WP-D)
16/17 — the one failure is jsdom refusing location.href navigation, not app code
Executions (WP-E)
56/56 + 6/6 for the ?open= deep link
Quick (WP-F)
26/26
Scheduler (WP-F)
19/19
Every page loads with a clean console and renders its core content from real data. Shell coverage:
ESC closes an open modal, the theme button flips data-theme (and back), Ctrl+K opens the command
palette (ESC closes it), and Pulse.confirm renders a themed dialog, escapes its message and
resolves false on ESC.
WebSocket — lt.ws.connect is called with maxRetries: Number.MAX_SAFE_INTEGER and reconnectDelay: 2000 (upstream's default of 10 retries would abandon live updates permanently
after ~5 minutes of downtime), with a raw-WebSocket fallback if lt.ws is missing. Disconnects
set the header status dot to Disconnected; a reconnect toasts "Live updates reconnected" and
triggers a refresh.
Not verified: no headless browser exists on this machine (chromium, chromium-browser, google-chrome all absent; no puppeteer/playwright installed), so CSP enforcement was verified
from response headers and by static audit rather than by a real browser refusing a resource. A
manual click-through under Chrome after deploy is worth one pass.
Behavioural notes / improvements
Run modal for every workflow. Execute always opens a modal — parameter inputs generated from definition.params (with required-field validation and a Dry Run checkbox) when the workflow
takes parameters, and a plain confirmation when it does not. The old flow used a native confirm().
Server-driven Manual/Automated tabs on Executions, with live counts, search over
command/id/workflow, status filter, "Showing X of Y" and Load More.
?open=<execution-id> deep link opens the detail modal directly and strips the parameter
from the URL — used by cross-page navigation from the Dashboard, Quick Command and Workflows.
WebSocket status dot in the header (connecting/connected/disconnected) plus a
"Refreshed: HH:MM:SS" stamp, so a stale page is now obvious.
Themed confirm dialogs everywhere: zero confirm()/alert() calls remain in the frontend.
Responsive layout comes from base.css — the tables collapse to labelled cards, the nav
becomes a drawer, and modals go full-bleed on small screens.
Reduced motion respected: the design system's prefers-reduced-motion blocks apply, and
Pulse's own running-row pulse animation opts out under the same query.
These substantially close #24 (mobile), #25 (alert/confirm consistency, WS indicator) and #32 (reduced motion).
Follow-ups
Upstream issue for web_template (drafted separately): unrenderable node/layout.ejs (en-dash
comment delimiters), README vs CSS mismatches (.lt-modal-backdrop/.lt-modal-overlay, .lt-alert-warning/.lt-alert--warning, .lt-field-hint/.lt-form-hint), missing .lt-field-error and .lt-modal-lg, lt.pagination option names, lt.init silently ignoring csrf/skipBoot, and the lt.ws.connect give-up-after-10-retries default.
.lt-modal-lg, .lt-field-error and .is-invalid live in public/assets/app.css only because
base.css lacks them; delete them here once they land upstream.
Post-merge: watch the Gitea Action + deploy webhook, then curl the prod routes through Authelia
and tail journalctl -u pulse for CSP violations or render errors.
## Summary
Pulse's UI was a single hand-rolled static page — `public/index.html`, 3161 lines, ~830 lines of
inline `<style>`, ~2000 lines of inline JS and 58 inline `onclick`/`onchange` handlers — served by
`express.static('public')`, with `public/base.js` a git **symlink** into a sibling `web_template`
checkout that only happens to exist on CT122.
This PR replaces it with six server-rendered EJS pages inside the shared LotusGuild chrome, a
vendored copy of the design system, a strict nonce-based CSP, and a small shared frontend runtime
(`window.Pulse`) that page modules plug into. **No API behaviour changes**: `/api/*` responses are
byte-identical to `main` (verified, see below), and the `commandHistory` / `pulse_executionView`
localStorage keys are preserved.
Feature parity was mandatory and is complete; the redesign also picks up a set of behavioural
improvements that fall out of the design system (see *Behavioural notes*).
## What changed by area
**Server (`server.js`, `lib/`)**
- helmet with a strict nonce CSP: `default-src 'self'`, `script-src 'self' 'nonce-…'`,
`script-src-attr 'none'`, `object-src 'none'`, `frame-ancestors 'none'`;
`PULSE_CSP_REPORT_ONLY=1` switches to report-only with `report-uri /csp-report`.
- Static mounts moved to `/web_template` and `/assets` (the root `public/` mount is gone), with
`maxAge: 1h` and `?v=<pkg version>-<web_template sha>` cache busting.
- Six page routes driven by a `PAGES` table in `lib/nav.js`; `lib/render.js` does two-step EJS
rendering (page view → `body` → `layout.ejs`) and renders themed HTML error pages for 401/403/404.
- `lib/pageauth.js`: the users upsert extracted out of `authenticateSSO` and reused by page auth,
which returns themed HTML (not JSON) for 401/403. `/api/*` auth is untouched.
- `PULSE_DEV_READONLY=1` (alias `DISABLE_BACKGROUND_JOBS=1`) disables every background writer —
stale-execution recovery, execution cleanup, the scheduled-command processor, the stale-worker
sweep — so a dev instance can safely point at the production database. Each skip is logged.
- `package.json`: `"start": "node server.js"`, `"main"` corrected to `server.js`; `ejs 3.1.10` and
`helmet 8.1.0` pinned in `dependencies`; lockfile committed.
**Design system (`public/web_template/`)**
- `base.css`, `base.js` and a `VERSION` file are now **vendored as real files** (no symlink, no
runtime dependency on a sibling checkout). `scripts/sync-web-template.sh <checkout>` refreshes
them and rewrites `VERSION`. `.eslintignore` excludes the vendored tree.
- The vendored `layout.ejs` fixes upstream's en-dash comment delimiters (which make the upstream
file unrenderable) and adds a skip link, mobile drawer, theme button, WS status dot,
"Refreshed: HH:MM:SS" stamp, command palette and keyboard-shortcut modal.
**Shared frontend (`public/assets/app.js`, `app.css`)**
- `window.Pulse`: action registry + delegated `data-action` / `data-change-action` /
`data-input-action` / `data-submit-action` listeners, event bus, `Pulse.confirm` /
`Pulse.alertModal` (themed, promise-based), formatters, escaping, a 401→reload fetch wrapper,
the single WebSocket connection, and the **single** `lt.autoRefresh` registration (30 s).
- Page modules register with `Pulse.registerPage({ name, init, refresh, onEvent })` and never own
timers, listeners or the WebSocket.
**Pages (`views/pages/*.ejs`, `public/assets/pages/*.js|css`)**
- Dashboard, Workers, Workflows, Executions, Quick Command, Scheduler — each an EJS view plus a
page module confined to its own action prefix (`dash:`, `wk:`, `wf:`, `ex:`, `qc:`, `sc:`) and
DOM id prefix. Every dynamic string goes through `Pulse.esc`.
**Integration/cleanup (this work package)**
- `lib/render.js` error page used `.lt-alert-danger`, which does not exist in `base.css` → fixed to
`.lt-alert--error` (tree-wide grep for single-dash alert classes is now clean).
- `.lt-modal-lg` was used by three pages but defined by none (Executions faked it with an
id-scoped width rule). Added one shared rule in `app.css`, mirroring base.css's sizing
conventions and re-capping at its two breakpoints; removed the id-scoped duplicate.
- `.lt-field-error` was defined page-locally in `workflows.css`; moved it plus an `.is-invalid`
input state into `app.css` so every page shares them (`wf-input-invalid` → `is-invalid`).
- `views/pages/quick.ejs` used the non-existent `.lt-field-hint` → `.lt-form-hint`.
- Dashboard load failures now surface a toast (once per outage) instead of only a console error.
- Deleted `public/index.html` and the `public/base.js` symlink.
- README: rewritten Styling & Layout section (vendored design system, sync script, VERSION), a new
Web UI section (routes, the `Pulse.registerPage` contract, the CSP rule for contributors), the
new env vars, and a "Local development against a real database" note.
## Screenshots
None — server-rendered pages, verified via curl/jsdom.
## Verification performed
Local instance against the production MariaDB with `PULSE_DEV_READONLY=1 PORT=8099 HOST=127.0.0.1`.
Startup log confirms all four background writers skipped.
**Static / CI**
| Check | Result |
|---|---|
| `npx eslint --ext .js .` | **0 errors** (3 pre-existing warnings in `server.js`) |
| `npx jest` | **31/31 passed**, 1 suite |
| `grep -rnE 'on(click\|change\|submit\|input\|keydown)="' views/ public/assets/` | empty |
| `grep -rn '<script' views/ \| grep -v nonce=` | empty |
| `grep -rn autoRefresh public/assets/pages/` | empty |
| `grep -rnE '\b(confirm\|alert)\(' public/assets/pages/ public/assets/app.js` | empty (one comment) |
| `grep -rnE 'lt-alert-(danger\|warning\|success\|info)'` | empty |
**Route matrix** — six routes × {admin, employee} = 12 requests, all:
`200`, exactly `4` × `nonce="`, exactly one `.lt-nav-link active` and it points at the current
route, and a `content-security-policy` header containing `script-src-attr 'none'`.
| | / | /workers | /workflows | /executions | /quick | /scheduler |
|---|---|---|---|---|---|---|
| admin | 200 ✓ | 200 ✓ | 200 ✓ | 200 ✓ | 200 ✓ | 200 ✓ |
| employee | 200 ✓ | 200 ✓ | 200 ✓ | 200 ✓ | 200 ✓ | 200 ✓ |
Also: no auth headers → **401 HTML**; `Remote-Groups: guest` → **403 HTML** (themed page, now
rendering the corrected `.lt-alert--error`); `/index.html` → **404**; `/base.js` → **404**;
`/api/nope` → **404 JSON** (`{"error":"Not found"}`); all 18 `/assets/**` and `/web_template/*`
URLs → **200**. With `PULSE_CSP_REPORT_ONLY=1`: `Content-Security-Policy-Report-Only` present with
`report-uri /csp-report`, and no enforcing `Content-Security-Policy` header.
**API byte-compare vs `main`** — `main` checked out in a throwaway worktree with the same `.env`,
started on `:8098` and stopped within seconds (before its 60 s scheduler/stale-worker intervals
could fire), responses captured from both ports back-to-back with identical headers:
| Endpoint | Result |
|---|---|
| `/api/workers` | identical (371 B) |
| `/api/workflows` | identical (50 936 B) |
| `/api/executions?limit=5` | identical (1 180 B) |
| `/api/scheduled-commands` | identical (2 B) |
| `/api/user` | identical (103 B) |
Byte-identical, not merely equivalent — no timestamp drift to explain away.
**Behavioural QA (jsdom)** — each page's *live-rendered* HTML plus `base.js` + `app.js` + its page
module, with `fetch` stubbed from JSON captured off the running instance:
| Suite | Result |
|---|---|
| Integration (6 pages + shell) | **40/40** |
| Dashboard + Workers (WP-C) | ALL PASS |
| Workflows (WP-D) | 16/17 — the one failure is jsdom refusing `location.href` navigation, not app code |
| Executions (WP-E) | **56/56** + **6/6** for the `?open=` deep link |
| Quick (WP-F) | **26/26** |
| Scheduler (WP-F) | **19/19** |
Every page loads with a clean console and renders its core content from real data. Shell coverage:
ESC closes an open modal, the theme button flips `data-theme` (and back), Ctrl+K opens the command
palette (ESC closes it), and `Pulse.confirm` renders a themed dialog, escapes its message and
resolves `false` on ESC.
**WebSocket** — `lt.ws.connect` is called with `maxRetries: Number.MAX_SAFE_INTEGER` and
`reconnectDelay: 2000` (upstream's default of 10 retries would abandon live updates permanently
after ~5 minutes of downtime), with a raw-`WebSocket` fallback if `lt.ws` is missing. Disconnects
set the header status dot to *Disconnected*; a reconnect toasts "Live updates reconnected" and
triggers a refresh.
**Not verified:** no headless browser exists on this machine (`chromium`, `chromium-browser`,
`google-chrome` all absent; no `puppeteer`/`playwright` installed), so CSP enforcement was verified
from response headers and by static audit rather than by a real browser refusing a resource. A
manual click-through under Chrome after deploy is worth one pass.
## Behavioural notes / improvements
- **Run modal for every workflow.** Execute always opens a modal — parameter inputs generated from
`definition.params` (with required-field validation and a Dry Run checkbox) when the workflow
takes parameters, and a plain confirmation when it does not. The old flow used a native
`confirm()`.
- **Server-driven Manual/Automated tabs** on Executions, with live counts, search over
command/id/workflow, status filter, "Showing X of Y" and Load More.
- **`?open=<execution-id>` deep link** opens the detail modal directly and strips the parameter
from the URL — used by cross-page navigation from the Dashboard, Quick Command and Workflows.
- **WebSocket status dot** in the header (connecting/connected/disconnected) plus a
"Refreshed: HH:MM:SS" stamp, so a stale page is now obvious.
- **Themed confirm dialogs** everywhere: zero `confirm()`/`alert()` calls remain in the frontend.
- **Responsive layout** comes from base.css — the tables collapse to labelled cards, the nav
becomes a drawer, and modals go full-bleed on small screens.
- **Reduced motion respected**: the design system's `prefers-reduced-motion` blocks apply, and
Pulse's own running-row pulse animation opts out under the same query.
These substantially close **#24** (mobile), **#25** (alert/confirm consistency, WS indicator) and
**#32** (reduced motion).
## Follow-ups
- Upstream issue for `web_template` (drafted separately): unrenderable `node/layout.ejs` (en-dash
comment delimiters), README vs CSS mismatches (`.lt-modal-backdrop`/`.lt-modal-overlay`,
`.lt-alert-warning`/`.lt-alert--warning`, `.lt-field-hint`/`.lt-form-hint`), missing
`.lt-field-error` and `.lt-modal-lg`, `lt.pagination` option names, `lt.init` silently ignoring
`csrf`/`skipBoot`, and the `lt.ws.connect` give-up-after-10-retries default.
- `.lt-modal-lg`, `.lt-field-error` and `.is-invalid` live in `public/assets/app.css` only because
base.css lacks them; delete them here once they land upstream.
- Post-merge: watch the Gitea Action + deploy webhook, then curl the prod routes through Authelia
and tail `journalctl -u pulse` for CSP violations or render errors.
Closes #2
Relates to #24, #25, #32
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01HamVMDrA8RqhyxmUHgiqRp
- Dashboard, Workers, Workflows, Executions, Quick Command, Scheduler pages on the
shared layout, all behaviour via Pulse delegated actions (no inline handlers)
- Executions: server-driven Manual/Automated views, compare mode, detail modal with all
log types, ?open= deep link, cross-page re-run via sessionStorage
- Workflows: run modal (dry-run available for every workflow), create modal pre-filled
with the example definition
- Fix EJS comment in page-header partial that broke rendering
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HamVMDrA8RqhyxmUHgiqRp
- Fix design-system class names that do not exist in base.css (.lt-alert--error,
.lt-form-hint); add shared .lt-modal-lg, .lt-field-error and .is-invalid rules
- Remove public/index.html and the public/base.js symlink
- package.json: start script, correct main
- README: vendored design system, Web UI section, new env vars, read-only dev mode
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HamVMDrA8RqhyxmUHgiqRp
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Pulse's UI was a single hand-rolled static page —
public/index.html, 3161 lines, ~830 lines ofinline
<style>, ~2000 lines of inline JS and 58 inlineonclick/onchangehandlers — served byexpress.static('public'), withpublic/base.jsa git symlink into a siblingweb_templatecheckout that only happens to exist on CT122.
This PR replaces it with six server-rendered EJS pages inside the shared LotusGuild chrome, a
vendored copy of the design system, a strict nonce-based CSP, and a small shared frontend runtime
(
window.Pulse) that page modules plug into. No API behaviour changes:/api/*responses arebyte-identical to
main(verified, see below), and thecommandHistory/pulse_executionViewlocalStorage keys are preserved.
Feature parity was mandatory and is complete; the redesign also picks up a set of behavioural
improvements that fall out of the design system (see Behavioural notes).
What changed by area
Server (
server.js,lib/)default-src 'self',script-src 'self' 'nonce-…',script-src-attr 'none',object-src 'none',frame-ancestors 'none';PULSE_CSP_REPORT_ONLY=1switches to report-only withreport-uri /csp-report./web_templateand/assets(the rootpublic/mount is gone), withmaxAge: 1hand?v=<pkg version>-<web_template sha>cache busting.PAGEStable inlib/nav.js;lib/render.jsdoes two-step EJSrendering (page view →
body→layout.ejs) and renders themed HTML error pages for 401/403/404.lib/pageauth.js: the users upsert extracted out ofauthenticateSSOand reused by page auth,which returns themed HTML (not JSON) for 401/403.
/api/*auth is untouched.PULSE_DEV_READONLY=1(aliasDISABLE_BACKGROUND_JOBS=1) disables every background writer —stale-execution recovery, execution cleanup, the scheduled-command processor, the stale-worker
sweep — so a dev instance can safely point at the production database. Each skip is logged.
package.json:"start": "node server.js","main"corrected toserver.js;ejs 3.1.10andhelmet 8.1.0pinned independencies; lockfile committed.Design system (
public/web_template/)base.css,base.jsand aVERSIONfile are now vendored as real files (no symlink, noruntime dependency on a sibling checkout).
scripts/sync-web-template.sh <checkout>refreshesthem and rewrites
VERSION..eslintignoreexcludes the vendored tree.layout.ejsfixes upstream's en-dash comment delimiters (which make the upstreamfile unrenderable) and adds a skip link, mobile drawer, theme button, WS status dot,
"Refreshed: HH:MM:SS" stamp, command palette and keyboard-shortcut modal.
Shared frontend (
public/assets/app.js,app.css)window.Pulse: action registry + delegateddata-action/data-change-action/data-input-action/data-submit-actionlisteners, event bus,Pulse.confirm/Pulse.alertModal(themed, promise-based), formatters, escaping, a 401→reload fetch wrapper,the single WebSocket connection, and the single
lt.autoRefreshregistration (30 s).Pulse.registerPage({ name, init, refresh, onEvent })and never owntimers, listeners or the WebSocket.
Pages (
views/pages/*.ejs,public/assets/pages/*.js|css)page module confined to its own action prefix (
dash:,wk:,wf:,ex:,qc:,sc:) andDOM id prefix. Every dynamic string goes through
Pulse.esc.Integration/cleanup (this work package)
lib/render.jserror page used.lt-alert-danger, which does not exist inbase.css→ fixed to.lt-alert--error(tree-wide grep for single-dash alert classes is now clean)..lt-modal-lgwas used by three pages but defined by none (Executions faked it with anid-scoped width rule). Added one shared rule in
app.css, mirroring base.css's sizingconventions and re-capping at its two breakpoints; removed the id-scoped duplicate.
.lt-field-errorwas defined page-locally inworkflows.css; moved it plus an.is-invalidinput state into
app.cssso every page shares them (wf-input-invalid→is-invalid).views/pages/quick.ejsused the non-existent.lt-field-hint→.lt-form-hint.public/index.htmland thepublic/base.jssymlink.Web UI section (routes, the
Pulse.registerPagecontract, the CSP rule for contributors), thenew env vars, and a "Local development against a real database" note.
Screenshots
None — server-rendered pages, verified via curl/jsdom.
Verification performed
Local instance against the production MariaDB with
PULSE_DEV_READONLY=1 PORT=8099 HOST=127.0.0.1.Startup log confirms all four background writers skipped.
Static / CI
npx eslint --ext .js .server.js)npx jestgrep -rnE 'on(click|change|submit|input|keydown)="' views/ public/assets/grep -rn '<script' views/ | grep -v nonce=grep -rn autoRefresh public/assets/pages/grep -rnE '\b(confirm|alert)\(' public/assets/pages/ public/assets/app.jsgrep -rnE 'lt-alert-(danger|warning|success|info)'Route matrix — six routes × {admin, employee} = 12 requests, all:
200, exactly4×nonce=", exactly one.lt-nav-link activeand it points at the currentroute, and a
content-security-policyheader containingscript-src-attr 'none'.Also: no auth headers → 401 HTML;
Remote-Groups: guest→ 403 HTML (themed page, nowrendering the corrected
.lt-alert--error);/index.html→ 404;/base.js→ 404;/api/nope→ 404 JSON ({"error":"Not found"}); all 18/assets/**and/web_template/*URLs → 200. With
PULSE_CSP_REPORT_ONLY=1:Content-Security-Policy-Report-Onlypresent withreport-uri /csp-report, and no enforcingContent-Security-Policyheader.API byte-compare vs
main—mainchecked out in a throwaway worktree with the same.env,started on
:8098and stopped within seconds (before its 60 s scheduler/stale-worker intervalscould fire), responses captured from both ports back-to-back with identical headers:
/api/workers/api/workflows/api/executions?limit=5/api/scheduled-commands/api/userByte-identical, not merely equivalent — no timestamp drift to explain away.
Behavioural QA (jsdom) — each page's live-rendered HTML plus
base.js+app.js+ its pagemodule, with
fetchstubbed from JSON captured off the running instance:location.hrefnavigation, not app code?open=deep linkEvery page loads with a clean console and renders its core content from real data. Shell coverage:
ESC closes an open modal, the theme button flips
data-theme(and back), Ctrl+K opens the commandpalette (ESC closes it), and
Pulse.confirmrenders a themed dialog, escapes its message andresolves
falseon ESC.WebSocket —
lt.ws.connectis called withmaxRetries: Number.MAX_SAFE_INTEGERandreconnectDelay: 2000(upstream's default of 10 retries would abandon live updates permanentlyafter ~5 minutes of downtime), with a raw-
WebSocketfallback iflt.wsis missing. Disconnectsset the header status dot to Disconnected; a reconnect toasts "Live updates reconnected" and
triggers a refresh.
Not verified: no headless browser exists on this machine (
chromium,chromium-browser,google-chromeall absent; nopuppeteer/playwrightinstalled), so CSP enforcement was verifiedfrom response headers and by static audit rather than by a real browser refusing a resource. A
manual click-through under Chrome after deploy is worth one pass.
Behavioural notes / improvements
definition.params(with required-field validation and a Dry Run checkbox) when the workflowtakes parameters, and a plain confirmation when it does not. The old flow used a native
confirm().command/id/workflow, status filter, "Showing X of Y" and Load More.
?open=<execution-id>deep link opens the detail modal directly and strips the parameterfrom the URL — used by cross-page navigation from the Dashboard, Quick Command and Workflows.
"Refreshed: HH:MM:SS" stamp, so a stale page is now obvious.
confirm()/alert()calls remain in the frontend.becomes a drawer, and modals go full-bleed on small screens.
prefers-reduced-motionblocks apply, andPulse's own running-row pulse animation opts out under the same query.
These substantially close #24 (mobile), #25 (alert/confirm consistency, WS indicator) and
#32 (reduced motion).
Follow-ups
web_template(drafted separately): unrenderablenode/layout.ejs(en-dashcomment delimiters), README vs CSS mismatches (
.lt-modal-backdrop/.lt-modal-overlay,.lt-alert-warning/.lt-alert--warning,.lt-field-hint/.lt-form-hint), missing.lt-field-errorand.lt-modal-lg,lt.paginationoption names,lt.initsilently ignoringcsrf/skipBoot, and thelt.ws.connectgive-up-after-10-retries default..lt-modal-lg,.lt-field-errorand.is-invalidlive inpublic/assets/app.cssonly becausebase.css lacks them; delete them here once they land upstream.
and tail
journalctl -u pulsefor CSP violations or render errors.Closes #2
Relates to #24, #25, #32
🤖 Generated with Claude Code
https://claude.ai/code/session_01HamVMDrA8RqhyxmUHgiqRp