Context
The collectors gather data; the panel renders it. The Workflow Panel is the unified UI surface (Issues Stream primary, Tools bar secondary, four data zones) that lives in the admin bar and frontend footer when {PREFIX}_DEV_MODE is on.
Three pieces ship together because they share the same JS thin shell and CSS:
Workflow Panel — admin-bar toggle + slide-in panel + tab switcher + per-collector view.
Visual Timeline — horizontal timeline of timed events from any collector implementing Timeline_Event_Provider.
Console_Log_Collector — JS-only browser-console capture. Memory only; never sent to the server.
Expected Outcome
src/Dev/UI/Workflow_Panel.php panel container, tabs, hook setup
src/Dev/UI/Visual_Timeline.php renders events from Timeline_Event_Provider collectors
src/Dev/Collectors/Console_Log_Collector.php JS-side capture; PHP-side stub for the panel tab
src/Dev/UI/assets/panel.js JS thin shell (memory store, tab switching, console capture)
src/Dev/UI/assets/panel.css panel styles
src/Dev/UI/assets/timeline.js timeline interactions (zoom, hover)
tests/Dev/UI/Workflow_PanelTest.php
tests/Dev/UI/Visual_TimelineTest.php
tests/Dev/Collectors/Console_Log_CollectorTest.php
CHANGELOG.md (Unreleased entry)
Workflow Panel behaviour:
Admin-bar toggle: button labelled "Dev Monitor" with a count badge for issues found.
Panel slides in from the bottom (configurable side via per-user preference stored in localStorage).
Top bar: Tools (collector list with per-collector enable/disable from Feature_Selector).
Main area: 4 zones — Issues Stream (primary), Selected Collector View, Visual Timeline, Console Log.
Closes on Escape; remembers open / closed state per-page in localStorage.
Visual Timeline behaviour:
Reads events from every collector implementing Timeline_Event_Provider.
Lays events on a horizontal axis scaled to total request time.
Hover to see event metadata (label, phase, timing).
Zero dependency on JS frameworks — vanilla DOM.
Console_Log_Collector behaviour:
JS hooks console.log/warn/error/info/debug from page load onward.
Stored in an in-memory ring buffer (default 200 entries).
Rendered as a tab in the panel.
Never serialised to the server. No fetch, no XHR, no form post. Purely client-side.
PHP side is a stub renderer that reads nothing and produces a <div data-dev-monitor="console-log"> mount point.
Implementation guidance
Self-contained brief. PHP container + vanilla JS thin shell + zero JS framework deps.
Step-by-step
Build Workflow_Panel as a Singleton:
setup() registers admin_bar_menu (priority 999), wp_footer, admin_footer, and asset enqueues.
render_admin_bar( WP_Admin_Bar $bar ) adds a "Dev Monitor" node with an issue count badge.
render_panel() outputs the slide-in container with tab switcher; data-fetched lazily via JS.
Build Visual_Timeline as a Singleton:
render() emits a <canvas data-dev-monitor="timeline"> plus a <script type="application/json" data-dev-monitor="timeline-data"> block containing the merged event list (escaped JSON).
collect_events() iterates Dev_Loader::get_collectors(), picks ones implementing Timeline_Event_Provider, calls timeline_events() on each, merges and sorts by ts_ms.
Build Console_Log_Collector as a multi-instance class (it has no PHP-side state):
PHP side: stub render that emits <div data-dev-monitor="console-log"></div> mount point.
JS side: subscribes to console.log/warn/error/info/debug, stores last 200 in a module-level array, never sends.
Build the JS in src/Dev/UI/assets/panel.js:
On DOMContentLoaded, find the panel, wire tab switching.
Read state from localStorage (dev-monitor-open, dev-monitor-side).
Console capture: replace window.console.log (and friends) with a wrapper that calls the original AND pushes to the in-memory ring.
Test that JS makes no network calls. A simple grep test over panel.js asserting absence of fetch(, XMLHttpRequest, navigator.sendBeacon, new Image( (image tracking trick).
PHP tests for Workflow_Panel::render_panel() (snapshot of HTML output), Visual_Timeline::collect_events() (with mock collectors), and the CSP-safety of the JSON data block.
Reference patterns
public function render_panel (): string {
if ( wp_doing_ajax () || ( defined ( 'REST_REQUEST ' ) && REST_REQUEST ) ) {
return '' ;
}
ob_start ();
?>
<div id="dev-monitor-panel" data-state="<?php echo esc_attr ( $ this ->initial_state () ); ?> ">
<div class="dev-monitor-tabs">
<?php foreach ( Dev_Loader::get_instance ()->get_collectors () as $ c ) : ?>
<button data-tab="<?php echo esc_attr ( $ c ->id () ); ?> ">
<?php echo esc_html ( $ c ->label () ); ?>
</button>
<?php endforeach ; ?>
</div>
<div class="dev-monitor-body">
<?php foreach ( Dev_Loader::get_instance ()->get_collectors () as $ c ) : ?>
<?php if ( $ c instanceof Renderable ) : ?>
<section data-tab-panel="<?php echo esc_attr ( $ c ->id () ); ?> ">
<?php echo $ c ->render (); // already escaped inside collector ?>
</section>
<?php endif ; ?>
<?php endforeach ; ?>
</div>
</div>
<?php
return (string ) ob_get_clean ();
}
// Console capture, vanilla JS, no deps. Memory only.
( function ( ) {
const RING_SIZE = 200 ;
const ring = [ ] ;
const original = { } ;
[ 'log' , 'warn' , 'error' , 'info' , 'debug' ] . forEach ( function ( level ) {
original [ level ] = window . console [ level ] . bind ( window . console ) ;
window . console [ level ] = function ( ) {
const args = Array . from ( arguments ) ;
ring . push ( { ts : Date . now ( ) , level : level , args : args } ) ;
if ( ring . length > RING_SIZE ) ring . shift ( ) ;
original [ level ] . apply ( window . console , arguments ) ;
} ;
} ) ;
window . __devMonitorConsoleRing = ring ; // panel reads this, never serialises
} ) ( ) ;
Complete sample: panel.js end-to-end (browser, vanilla JS)
// src/Dev/UI/assets/panel.js
( function ( ) {
'use strict' ;
// ---- Module state ----
const STORAGE_KEY = 'devMonitorPanel' ;
const CONSOLE_RING_SIZE = 200 ;
const consoleRing = [ ] ;
// ---- Console capture (memory only, never serialised) ----
const originalConsole = { } ;
[ 'log' , 'warn' , 'error' , 'info' , 'debug' ] . forEach ( function ( level ) {
originalConsole [ level ] = window . console [ level ] . bind ( window . console ) ;
window . console [ level ] = function ( ) {
const args = Array . from ( arguments ) . map ( function ( a ) {
try { return typeof a === 'object' ? JSON . stringify ( a ) : String ( a ) ; }
catch ( e ) { return '[unstringifiable]' ; }
} ) ;
consoleRing . push ( { ts : Date . now ( ) , level : level , message : args . join ( ' ' ) } ) ;
if ( consoleRing . length > CONSOLE_RING_SIZE ) consoleRing . shift ( ) ;
originalConsole [ level ] . apply ( window . console , arguments ) ;
} ;
} ) ;
// Expose for the panel tab to read; never sent over the wire.
window . __devMonitorConsoleRing = consoleRing ;
// ---- Panel state persistence ----
function loadState ( ) {
try {
const raw = window . localStorage . getItem ( STORAGE_KEY ) ;
return raw ? JSON . parse ( raw ) : { open : false , side : 'bottom' } ;
} catch ( e ) {
return { open : false , side : 'bottom' } ;
}
}
function saveState ( state ) {
try { window . localStorage . setItem ( STORAGE_KEY , JSON . stringify ( state ) ) ; }
catch ( e ) { /* private mode — silently degrade */ }
}
// ---- Tab switching ----
function wireTabs ( panel ) {
const buttons = panel . querySelectorAll ( '.dev-monitor-tabs button' ) ;
const panes = panel . querySelectorAll ( '[data-tab-panel]' ) ;
buttons . forEach ( function ( btn ) {
btn . addEventListener ( 'click' , function ( ) {
const target = btn . getAttribute ( 'data-tab' ) ;
buttons . forEach ( function ( b ) { b . classList . toggle ( 'active' , b === btn ) ; } ) ;
panes . forEach ( function ( p ) {
p . style . display = p . getAttribute ( 'data-tab-panel' ) === target ? 'block' : 'none' ;
} ) ;
if ( target === 'console-log' ) renderConsoleTab ( panel ) ;
} ) ;
} ) ;
// activate first tab
if ( buttons . length > 0 ) buttons [ 0 ] . click ( ) ;
}
// ---- Console tab renderer (reads from in-memory ring) ----
function renderConsoleTab ( panel ) {
const mount = panel . querySelector ( '[data-dev-monitor="console-log"]' ) ;
if ( ! mount ) return ;
mount . innerHTML = consoleRing . map ( function ( entry ) {
const time = new Date ( entry . ts ) . toISOString ( ) . slice ( 11 , 23 ) ;
return '<div class="log-line log-' + entry . level + '">' +
'<span class="ts">' + time + '</span> ' +
'<span class="level">' + entry . level + '</span> ' +
'<span class="msg">' + escapeHtml ( entry . message ) + '</span>' +
'</div>' ;
} ) . join ( '' ) ;
}
function escapeHtml ( s ) {
return s . replace ( / [ & < > " ' ] / g, function ( c ) {
return { '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' } [ c ] ;
} ) ;
}
// ---- Toggle from admin bar ----
function wireToggle ( panel ) {
const trigger = document . querySelector ( '#wp-admin-bar-dev-monitor' ) ;
if ( ! trigger ) return ;
trigger . addEventListener ( 'click' , function ( e ) {
e . preventDefault ( ) ;
const state = loadState ( ) ;
state . open = ! state . open ;
panel . setAttribute ( 'data-state' , state . open ? 'open' : 'closed' ) ;
saveState ( state ) ;
} ) ;
// Escape closes
document . addEventListener ( 'keydown' , function ( e ) {
if ( e . key === 'Escape' ) {
panel . setAttribute ( 'data-state' , 'closed' ) ;
const state = loadState ( ) ;
state . open = false ;
saveState ( state ) ;
}
} ) ;
}
// ---- Bootstrap ----
document . addEventListener ( 'DOMContentLoaded' , function ( ) {
const panel = document . getElementById ( 'dev-monitor-panel' ) ;
if ( ! panel ) return ;
const state = loadState ( ) ;
panel . setAttribute ( 'data-state' , state . open ? 'open' : 'closed' ) ;
wireToggle ( panel ) ;
wireTabs ( panel ) ;
} ) ;
} ) ( ) ;
Edge cases
Admin-bar position: priority 999 places "Dev Monitor" at the right edge. If a project already uses 999, bump to 1000.
LocalStorage may be disabled (private mode). Wrap reads/writes in try/catch; default to "closed" panel state on failure.
Console capture must not break sites that wrap console.log themselves. Always call the original.
Translation: timeline labels and tab labels go through __( ... 'wp-toolkit' ).
Pre-PR self-check
Acceptance Criteria
Notes
Depends on Add Dev_Loader (DEV_MODE-gated boot) #19 merged (loader hooks the panel's enqueue).
Depends on at least 2 collectors from Add Dev Monitor — 14 collector implementations (parent) #20 merged (so the panel has something to display in the smoke test).
AI chatbox tab is not in this issue. It is a Sprint 4 plan-only spec, gated on WP 7.0.
WP_Profile_Collector (native stage timing) is a follow-up issue once stage hooks are stable.
PR target: release/v1.0.0. Branch: v1.0.0/task/workflow-panel-and-timeline. Commit subject: feat(dev): add Workflow Panel, Visual Timeline, Console_Log_Collector.
Context
The collectors gather data; the panel renders it. The Workflow Panel is the unified UI surface (Issues Stream primary, Tools bar secondary, four data zones) that lives in the admin bar and frontend footer when
{PREFIX}_DEV_MODEis on.Three pieces ship together because they share the same JS thin shell and CSS:
Timeline_Event_Provider.Expected Outcome
Workflow Panel behaviour:
localStorage).Feature_Selector).localStorage.Visual Timeline behaviour:
Timeline_Event_Provider.Console_Log_Collector behaviour:
console.log/warn/error/info/debugfrom page load onward.<div data-dev-monitor="console-log">mount point.Implementation guidance
Step-by-step
Workflow_Panelas a Singleton:setup()registersadmin_bar_menu(priority 999),wp_footer,admin_footer, and asset enqueues.render_admin_bar( WP_Admin_Bar $bar )adds a "Dev Monitor" node with an issue count badge.render_panel()outputs the slide-in container with tab switcher; data-fetched lazily via JS.Visual_Timelineas a Singleton:render()emits a<canvas data-dev-monitor="timeline">plus a<script type="application/json" data-dev-monitor="timeline-data">block containing the merged event list (escaped JSON).collect_events()iteratesDev_Loader::get_collectors(), picks ones implementingTimeline_Event_Provider, callstimeline_events()on each, merges and sorts byts_ms.Console_Log_Collectoras a multi-instance class (it has no PHP-side state):<div data-dev-monitor="console-log"></div>mount point.console.log/warn/error/info/debug, stores last 200 in a module-level array, never sends.src/Dev/UI/assets/panel.js:localStorage(dev-monitor-open,dev-monitor-side).window.console.log(and friends) with a wrapper that calls the original AND pushes to the in-memory ring.panel.jsasserting absence offetch(,XMLHttpRequest,navigator.sendBeacon,new Image((image tracking trick).Workflow_Panel::render_panel()(snapshot of HTML output),Visual_Timeline::collect_events()(with mock collectors), and the CSP-safety of the JSON data block.Reference patterns
Complete sample: panel.js end-to-end (browser, vanilla JS)
Edge cases
console.logthemselves. Always call the original.__( ... 'wp-toolkit' ).Pre-PR self-check
fetch,XHR,sendBeacon, or image-tracking inpanel.js(verified by grep test)## UnreleasedAcceptance Criteria
{PREFIX}_DEV_MODEis true (verified by test that sets the constant to false and asserts no enqueue)localStorageper-pageconsole.*calls and shows them in the tabfetch/XMLHttpRequestreferences in the JSCHANGELOG.mdentry added under## UnreleasedNotes
release/v1.0.0. Branch:v1.0.0/task/workflow-panel-and-timeline. Commit subject:feat(dev): add Workflow Panel, Visual Timeline, Console_Log_Collector.