The current app.js (~3100 lines) is a monolithic file mixing state management, DOM rendering, SSE streaming, event handling, animation, and business logic. This design document specifies a clean modular architecture using vanilla ES modules (<script type="module">) with no build step, no bundler, and no framework.
| Decision | Rationale |
|---|---|
ES modules via import/export |
No build tools needed; native browser support; clean dependency graph |
| Observable state store (pub/sub) | Single source of truth; UI auto-updates; eliminates scattered mutations |
Single RequirementsTable class |
One component renders into any container; eliminates 3× duplicated table code |
| Event bus for cross-module comms | Decouples modules; enables future extensibility |
| Stage controller for panel routing | Fixes the meet/analyze panel confusion; centralizes navigation logic |
index.html
└── <script type="module" src="js/main.js">
main.js
├── store.js (state store — no dependencies)
├── event-bus.js (event bus — no dependencies)
├── api/
│ ├── sse-client.js (generic SSE/streaming helpers)
│ ├── analyze-api.js (meet + gap analysis endpoints)
│ ├── dispatch-api.js (issue creation + agent assignment)
│ ├── deploy-api.js (deploy + validate endpoints)
│ └── index.js (re-exports)
├── components/
│ ├── requirements-table.js (unified table component)
│ ├── activity-log.js (agent log + activity feed)
│ ├── progress-strip.js (loading step indicators)
│ ├── meeting-card.js (M365 meeting card)
│ ├── dispatch-progress.js (dispatch progress bar)
│ ├── qa-workflow.js (deploy + validate workflow UI)
│ ├── toast.js (notification toasts)
│ └── completion-panel.js (final stats view)
├── controllers/
│ ├── stage-controller.js (panel routing + stage transitions)
│ ├── slide-over.js (slide-over panel management)
│ └── header-controller.js (header nav + status badge)
├── loop/
│ ├── loop-renderer.js (stage node cards + SVG loop)
│ └── particle-system.js (LoopParticleSystem class — moved as-is)
└── utils/
├── dom.js (escapeHtml, element helpers)
└── format.js (time formatting, status text)
┌──────────┐
│ main.js │
└────┬─────┘
┌─────────┬──┴──┬──────────┬────────────┐
▼ ▼ ▼ ▼ ▼
store.js event-bus api/* controllers/* components/*
│ │ │ │ │
│ │ ├── sse-client.js │
│ │ │ │
▼ ▼ ▼ ▼
(no deps) (no deps) store.js store.js
event-bus.js event-bus.js
utils/*
Rules:
store.jsandevent-bus.jsdepend on nothing (leaf modules)utils/*depend on nothingapi/*modules depend onstore.js,event-bus.js, andsse-client.jscomponents/*depend onstore.js,event-bus.js, andutils/*controllers/*depend onstore.js,event-bus.js,components/*main.jswires everything together — it's the only module that imports from all layers
// js/store.js
class Store {
constructor(initialState) { ... }
/** Get a deep-frozen snapshot of current state */
getState(): State
/** Merge a partial patch into state (shallow merge at top level, deep merge for nested objects).
* Notifies all subscribers whose watched paths are affected. */
setState(patch: Partial<State>): void
/** Subscribe to changes on a specific state path (dot-notation).
* Returns an unsubscribe function.
* Examples: subscribe('requirements', cb), subscribe('loop.stages.meet', cb) */
subscribe(path: string, callback: (newValue, oldValue, fullState) => void): () => void
/** Subscribe to ANY state change. Use sparingly. */
subscribeAll(callback: (state, patch) => void): () => void
/** Batch multiple setState calls — listeners fire once at the end. */
batch(fn: () => void): void
/** Reset state to initial values */
reset(): void
}
export const store = new Store(INITIAL_STATE);const INITIAL_STATE = {
// ─── Meeting / Extraction ──────────────────────────
meeting: {
name: '', // user input meeting name
info: null, // { title, date, participants[], summary } from M365
},
// ─── Requirements (SINGLE SOURCE OF TRUTH) ────────
requirements: [], // string[] — raw requirement texts
// ─── Gap Analysis ──────────────────────────────────
gaps: [], // GapResult[] — { id, requirement, gap, currentState,
// details, estimatedEffort, complexity, hasGap, selected }
gapAnalysis: {
phase: 'idle', // 'idle' | 'selecting' | 'analyzing' | 'reviewed'
analyzingIds: new Set(), // IDs currently being analyzed
selectedIndices: [], // indices selected for analysis
},
// ─── Epic ──────────────────────────────────────────
epic: {
number: 0,
url: '',
},
// ─── Dispatch / Build ──────────────────────────────
dispatch: {
inProgress: false,
dispatchedGapIds: new Set(), // gap IDs that have been dispatched
agentAssignments: {}, // { [gapId]: 'cloud' | 'local' | 'developer' }
results: [], // DispatchResult[] per gap
totalItems: 0,
completedItems: 0,
},
issues: [], // CreatedIssue[] — { number, url, title, gapId }
// ─── Deploy & Validate ─────────────────────────────
deploy: {
url: '', // deployed URL
status: 'idle', // 'idle' | 'deploying' | 'deployed' | 'failed'
},
validation: {
status: 'idle', // 'idle' | 'validating' | 'complete' | 'failed'
results: [], // ValidationResult[] — { requirement, passed, details }
validatingIndex: -1, // currently validating requirement index
},
// ─── Loop / Pipeline ──────────────────────────────
loop: {
iteration: 1,
activeStage: null, // 'meet' | 'analyze' | 'build' | 'verify' | null
detailPanelOpen: null, // which stage's slide-over is open
stages: {
meet: { status: 'idle', metrics: {}, startTime: null, endTime: null },
analyze: { status: 'idle', metrics: {}, startTime: null, endTime: null },
build: { status: 'idle', metrics: {}, startTime: null, endTime: null },
verify: { status: 'idle', metrics: {}, startTime: null, endTime: null },
},
},
// ─── UI Navigation ────────────────────────────────
ui: {
activePanel: 'panel-analyze', // current visible panel ID
activePhase: 'meeting', // header nav highlight
completedPhases: new Set(), // phases marked completed
statusText: 'Ready',
statusType: '', // '' | 'processing' | 'error'
},
};The store uses dot-notation paths so components only re-render when their data changes:
// RequirementsTable only re-renders when requirements or gaps change
store.subscribe('requirements', (reqs) => table.onRequirementsChanged(reqs));
store.subscribe('gaps', (gaps) => table.onGapsChanged(gaps));
// Loop renderer only re-renders when loop state changes
store.subscribe('loop.stages', (stages) => loopRenderer.update(stages));
// Header only updates when ui.statusText changes
store.subscribe('ui.statusText', (text) => headerController.setStatus(text));// js/event-bus.js
class EventBus {
/** Emit an event with optional payload */
emit(event: string, payload?: any): void
/** Subscribe to an event. Returns unsubscribe function. */
on(event: string, callback: (payload) => void): () => void
/** Subscribe once — auto-unsubscribes after first call */
once(event: string, callback: (payload) => void): () => void
/** Remove all listeners for an event (or all events if no arg) */
off(event?: string): void
}
export const bus = new EventBus();| Event Name | Payload | Emitted By | Consumed By |
|---|---|---|---|
analysis:start |
{ meetingName } |
analyze-api |
stage-controller, loop-renderer, meeting-card |
analysis:meeting-found |
{ title, date, participants } |
analyze-api |
meeting-card, loop-renderer |
analysis:requirements-loaded |
{ requirements[] } |
analyze-api |
requirements-table, loop-renderer |
analysis:epic-created |
{ number, url } |
analyze-api |
requirements-table header |
analysis:complete |
{} |
analyze-api |
stage-controller |
gaps:analysis-start |
{ selectedIndices[] } |
analyze-api |
requirements-table, progress-strip |
gaps:item-started |
{ id } |
analyze-api |
requirements-table |
gaps:item-complete |
{ gap } |
analyze-api |
requirements-table, loop-renderer |
gaps:all-complete |
{} |
analyze-api |
stage-controller, requirements-table |
dispatch:start |
{ gapIds[], agentMap } |
dispatch-api |
stage-controller, requirements-table |
dispatch:issue-created |
{ gapId, issue } |
dispatch-api |
requirements-table |
dispatch:item-complete |
{ gapId, status, result } |
dispatch-api |
requirements-table, dispatch-progress |
dispatch:all-complete |
{ results[] } |
dispatch-api |
stage-controller |
deploy:start |
{} |
deploy-api |
qa-workflow, loop-renderer |
deploy:complete |
{ url } |
deploy-api |
qa-workflow, loop-renderer |
deploy:failed |
{ error } |
deploy-api |
qa-workflow |
validate:start |
{ url } |
deploy-api |
qa-workflow, requirements-table |
validate:item-start |
{ requirementIndex } |
deploy-api |
requirements-table |
validate:item-complete |
{ result } |
deploy-api |
requirements-table |
validate:all-complete |
{} |
deploy-api |
qa-workflow, stage-controller |
stage:transition |
{ from, to } |
stage-controller |
loop-renderer, particle-system |
stage:detail-open |
{ stage } |
slide-over |
loop-renderer |
stage:detail-close |
{} |
slide-over |
loop-renderer |
log:entry |
{ source, message } |
any api module | activity-log |
toast:show |
{ message, type } |
any module | toast |
nav:phase-change |
{ phase } |
header-controller |
stage-controller |
nav:panel-change |
{ panelId } |
stage-controller |
header-controller |
// js/components/requirements-table.js
class RequirementsTable {
/**
* @param {Object} options
* @param {HTMLElement} options.container - DOM element to render into
* @param {Store} options.store - state store reference
* @param {EventBus} options.bus - event bus reference
* @param {'meet'|'analyze'|'build'|'verify'} options.mode - rendering mode
*/
constructor({ container, store, bus, mode })
/** Change the rendering mode (re-renders the table) */
setMode(mode: 'meet' | 'analyze' | 'build' | 'verify'): void
/** Get current mode */
getMode(): string
/** Force a full re-render (normally handled by subscriptions) */
render(): void
/** Clean up subscriptions and DOM */
destroy(): void
}| Column | Meet | Analyze | Build | Verify |
|---|---|---|---|---|
| Checkbox | — | ✓ (select for analysis) | ✓ (select for dispatch) | — |
| Requirement | ✓ (read-only, streams in) | ✓ (expandable, click to see detail) | ✓ (expandable) | ✓ (expandable) |
| Status | — | Pending → Analyzing → Gap Found/No Gap/Skipped | Dispatching → Assigned/Implemented/Failed | Pending → Validating → Pass/Fail |
| Complexity | — | ✓ (Low/Med/High/Critical badge) | ✓ (from gap analysis) | — |
| Agent Type | — | — | ✓ (dropdown: Local/Cloud/Developer) | — |
| Issue Link | — | — | ✓ (#number link for cloud/developer) | — |
| Validation | — | — | — | ✓ (Pass/Fail chip) |
const COLUMN_CONFIGS = {
meet: [
{ key: 'requirement', header: 'Requirement', width: 'auto', render: renderReqText },
],
analyze: [
{ key: 'checkbox', header: selectAllCheckbox, width: '48px', render: renderCheckbox },
{ key: 'requirement', header: 'Requirement', width: 'auto', render: renderReqExpandable },
{ key: 'status', header: 'Status', width: '110px', render: renderAnalyzeStatus },
{ key: 'complexity', header: 'Complexity', width: '100px', render: renderComplexity },
],
build: [
{ key: 'requirement', header: 'Requirement', width: 'auto', render: renderReqText },
{ key: 'agentType', header: 'Mode', width: '90px', render: renderAgentDropdown },
{ key: 'issue', header: 'Issue', width: '140px', render: renderIssueLink },
{ key: 'status', header: 'Status', width: '140px', render: renderDispatchStatus },
],
verify: [
{ key: 'requirement', header: 'Requirement', width: 'auto', render: renderReqExpandable },
{ key: 'status', header: 'Status', width: '100px', render: renderValidationStatus },
],
};-
Table element is created once per
RequirementsTableinstance. The<thead>is rebuilt when mode changes. The<tbody>is rebuilt when data changes. -
Data source — The table reads from
store.getState().requirements(for row count/text) andstore.getState().gaps(for analysis results, statuses). It never holds its own copy of the data. -
Incremental updates — For streaming scenarios (requirements arriving one at a time, gap results arriving one at a time), the table subscribes to fine-grained store paths:
requirements→ append new rowsgaps→ update existing rows with enrichment data (status chip, complexity badge, detail expansion)dispatch.results→ update issue links and dispatch statusesvalidation.results→ update pass/fail chips
-
Expandable detail rows — Each row can expand to show a detail sub-row (gap description, current state, implementation details, validation evidence). This is handled by a
<tr class="row-details-expandable">inserted after each data row, toggled via click. -
Single instance, re-mounted — When the slide-over opens, the table's container element may be moved/cloned into the slide-over. The
RequirementsTablesupports being in different containers by re-attaching to a new parent without losing state.
Each mode has a configurable toolbar rendered above the table:
const TOOLBAR_CONFIGS = {
meet: [], // no actions during streaming
analyze: [
{ id: 'toggleAll', label: 'Toggle All', icon: checkboxIcon, action: 'toggleAll' },
{ id: 'analyzeGaps', label: 'Analyze Gaps', icon: searchIcon, primary: true, badge: 'selectedCount', action: 'analyzeGaps' },
{ id: 'analyzeSkipped', label: 'Skipped', icon: searchIcon, badge: 'skippedCount', visible: false, action: 'analyzeSkipped' },
{ id: 'dispatch', label: 'Dispatch', icon: sendIcon, primary: true, badge: 'selectedCount', visible: false, action: 'dispatch' },
],
build: [
{ id: 'dispatchMore', label: 'Dispatch Remaining', icon: sendIcon, primary: true, badge: 'remainingCount', action: 'dispatchRemaining' },
{ id: 'done', label: 'Done', icon: checkIcon, action: 'finishDispatch' },
],
verify: [
{ id: 'launchQA', label: 'Ship & Validate', icon: sendIcon, primary: true, action: 'launchQA' },
],
};The current code maps both meet and analyze stages to panel-loading, which causes the "same content" bug. The new mapping:
// js/controllers/stage-controller.js
const STAGE_PANEL_MAP = {
meet: 'panel-meet', // NEW: dedicated panel for meet stage
analyze: 'panel-analyze-detail', // NEW: renamed from panel-loading
build: 'panel-build', // renamed from panel-issues
verify: 'panel-verify', // renamed from panel-qa
};However, since meet and analyze share the same physical panel-loading section in the HTML, we solve this differently: same panel, different content mode.
Revised approach — Keep one panel (panel-pipeline) for meet+analyze but use the RequirementsTable mode to control what's shown:
const STAGE_CONFIG = {
meet: {
panel: 'panel-pipeline', // shared panel for meet + analyze
tableMode: 'meet', // RequirementsTable shows streaming read-only list
showProgressStrip: true,
showMeetingCard: true,
showToolbar: false,
},
analyze: {
panel: 'panel-pipeline', // same panel
tableMode: 'analyze', // RequirementsTable shows checkboxes + gap status
showProgressStrip: true,
showMeetingCard: false,
showToolbar: true,
},
build: {
panel: 'panel-build',
tableMode: 'build',
showProgressStrip: false,
showMeetingCard: false,
showToolbar: true,
},
verify: {
panel: 'panel-verify',
tableMode: 'verify',
showProgressStrip: false,
showMeetingCard: false,
showToolbar: true,
},
};class StageController {
constructor({ store, bus, slideOver })
/** Transition from current stage to a new stage.
* Updates store, emits events, switches panels, updates table mode. */
transitionTo(stage: 'meet' | 'analyze' | 'build' | 'verify'): void
/** Mark a stage as complete and advance to the next waiting stage */
completeStage(stage: string): void
/** Open the appropriate panel for a stage (used for direct navigation) */
showStagePanel(stage: string): void
/** Get current stage */
getCurrentStage(): string | null
/** Show a specific panel by ID */
showPanel(panelId: string): void
}class SlideOverController {
constructor({ store, bus })
/** Open slide-over with content for a stage.
* Re-parents the stage's panel into the slide-over. */
open(stage: string): void
/** Close slide-over, return re-parented panel to main. */
close(): void
/** Is the slide-over currently open? */
isOpen(): boolean
/** Which stage is currently shown in the slide-over? */
currentStage(): string | null
}// js/api/sse-client.js
/**
* Generic SSE stream consumer that handles both EventSource and
* fetch+ReadableStream patterns used by the backend.
*/
/** For EventSource-based endpoints (GET /api/analyze) */
export function consumeSSE(url, handlers: { [eventType: string]: (data: any) => void }): { close: () => void }
/** For fetch-based SSE endpoints (POST /api/analyze-gaps, etc.) */
export async function consumeStreamingResponse(
url: string,
options: RequestInit,
handlers: { [eventType: string]: (data: any) => void }
): Promise<void>// js/api/analyze-api.js
export async function startMeetingAnalysis(meetingName: string): Promise<void>
// Uses consumeSSE for GET /api/analyze
// Writes to: store.meeting, store.requirements, store.epic, store.loop.stages.meet
// Emits: analysis:start, analysis:meeting-found, analysis:requirements-loaded, analysis:epic-created, analysis:complete, log:entry
export async function startGapAnalysis(selectedIndices: number[]): Promise<void>
// Uses consumeStreamingResponse for POST /api/analyze-gaps
// Writes to: store.gaps, store.gapAnalysis, store.loop.stages.analyze
// Emits: gaps:analysis-start, gaps:item-started, gaps:item-complete, gaps:all-complete, log:entry
// js/api/dispatch-api.js
export async function dispatchGaps(cloudGaps, localGaps, developerGaps): Promise<DispatchResult[]>
// Orchestrates cloud/local/developer dispatch
// Writes to: store.dispatch, store.issues, store.loop.stages.build
// Emits: dispatch:start, dispatch:issue-created, dispatch:item-complete, dispatch:all-complete, log:entry
export async function dispatchCloudGaps(gaps): Promise<DispatchResult[]>
export async function dispatchLocalGaps(gaps): Promise<DispatchResult[]>
export async function dispatchDeveloperGaps(gaps): Promise<DispatchResult[]>
// js/api/deploy-api.js
export async function runDeploy(): Promise<string>
// Writes to: store.deploy
// Emits: deploy:start, deploy:complete, deploy:failed, log:entry
export async function runValidation(url: string, requirements: string[]): Promise<void>
// Writes to: store.validation
// Emits: validate:start, validate:item-start, validate:item-complete, validate:all-complete, log:entrypublic/
├── index.html (updated: <script type="module" src="js/main.js">)
├── styles.css (unchanged initially)
├── app.js (DEPRECATED — kept during migration, then deleted)
│
└── js/
├── main.js Entry point. Imports all modules. Wires store + bus +
│ controllers + components. Attaches global event handlers.
│
├── store.js Observable state store. Path-based subscriptions.
│ Exports singleton `store` instance.
│
├── event-bus.js Lightweight pub/sub event bus.
│ Exports singleton `bus` instance.
│
├── api/
│ ├── index.js Re-exports all API functions for convenience.
│ ├── sse-client.js Generic SSE + streaming response helpers.
│ ├── analyze-api.js Meeting analysis + gap analysis.
│ ├── dispatch-api.js Issue creation + agent assignment.
│ └── deploy-api.js Deploy to Azure + Playwright validation.
│
├── components/
│ ├── requirements-table.js Unified table: multi-mode, subscribes to store.
│ ├── activity-log.js Agent log panel + activity feed widget.
│ ├── progress-strip.js Step indicators (M365 → Fetch → Extract → Epic).
│ ├── meeting-card.js M365 meeting card + brand banner.
│ ├── dispatch-progress.js Progress bar for dispatch panel.
│ ├── qa-workflow.js Deploy + Validate step UI (2-step workflow bar).
│ ├── toast.js Notification toasts.
│ └── completion-panel.js Final stats + "View Repository" actions.
│
├── controllers/
│ ├── stage-controller.js Stage transitions, panel routing, table mode switching.
│ ├── slide-over.js Slide-over open/close, panel re-parenting.
│ └── header-controller.js Phase nav tabs, status badge, loop header info.
│
├── loop/
│ ├── loop-renderer.js Stage node cards rendering + state-to-class mapping.
│ └── particle-system.js LoopParticleSystem class (SVG particle animation).
│
└── utils/
├── dom.js escapeHtml(), createElement helpers.
└── format.js Time formatting, status label maps.
| File | Est. Lines | Responsibility |
|---|---|---|
store.js |
~120 | State container + path subscriptions |
event-bus.js |
~60 | Pub/sub |
sse-client.js |
~80 | SSE parsing |
analyze-api.js |
~200 | Meet + gap analysis streaming |
dispatch-api.js |
~250 | Cloud/local/developer dispatch |
deploy-api.js |
~150 | Deploy + validate |
requirements-table.js |
~400 | Unified table (largest component) |
activity-log.js |
~80 | Log rendering |
progress-strip.js |
~60 | Step indicators |
meeting-card.js |
~100 | M365 branding |
qa-workflow.js |
~150 | Deploy/validate workflow UI |
toast.js |
~40 | Notifications |
completion-panel.js |
~60 | Stats view |
stage-controller.js |
~200 | Panel routing + stage logic |
slide-over.js |
~100 | Slide-over management |
header-controller.js |
~100 | Header nav |
loop-renderer.js |
~150 | Stage node cards |
particle-system.js |
~250 | Particle animation (existing code) |
dom.js |
~30 | Utilities |
format.js |
~30 | Utilities |
main.js |
~100 | Wiring |
| Total | ~2710 | Down from ~3100 monolith |
// js/main.js — Entry point
import { store } from './store.js';
import { bus } from './event-bus.js';
import { StageController } from './controllers/stage-controller.js';
import { SlideOverController } from './controllers/slide-over.js';
import { HeaderController } from './controllers/header-controller.js';
import { RequirementsTable } from './components/requirements-table.js';
import { ActivityLog } from './components/activity-log.js';
import { ProgressStrip } from './components/progress-strip.js';
import { MeetingCard } from './components/meeting-card.js';
import { QAWorkflow } from './components/qa-workflow.js';
import { Toast } from './components/toast.js';
import { CompletionPanel } from './components/completion-panel.js';
import { LoopRenderer } from './loop/loop-renderer.js';
import { LoopParticleSystem } from './loop/particle-system.js';
import * as api from './api/index.js';
// ─── Initialize components ───────────────────────────────
const slideOver = new SlideOverController({ store, bus });
const stageController = new StageController({ store, bus, slideOver });
const headerController = new HeaderController({ store, bus, stageController });
const reqTable = new RequirementsTable({
container: document.getElementById('unifiedTableContainer'),
store,
bus,
mode: 'meet',
});
const activityLog = new ActivityLog({ store, bus });
const progressStrip = new ProgressStrip({ store, bus });
const meetingCard = new MeetingCard({ store, bus });
const qaWorkflow = new QAWorkflow({ store, bus });
const toast = new Toast({ bus });
const completionPanel = new CompletionPanel({ store, bus });
const loopRenderer = new LoopRenderer({ store, bus });
const particleSystem = new LoopParticleSystem();
// ─── Wire global actions ─────────────────────────────────
// Expose minimal functions to window for onclick handlers in HTML
// (gradually migrate to addEventListener in components)
window.startAnalysis = () => {
const name = document.getElementById('meetingNameInput')?.value?.trim();
if (name) api.startMeetingAnalysis(name);
};
window.startGapAnalysis = () => { /* delegate to stageController */ };
window.dispatchSelected = () => { /* delegate to stageController */ };
// ... etc
// ─── Init ────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
particleSystem.init();
stageController.showPanel('panel-analyze');
});- Add
<script type="module" src="js/main.js">toindex.htmlalongside the existing<script src="app.js">. Both scripts load.main.jsstarts empty. - Create the file structure — empty files with just
export {}so imports don't fail. - Copy
escapeHtml()andisNoGap()toutils/dom.jsandutils/format.js— export them. Haveapp.jscall the util versions (or keep both alive temporarily).
- Implement
store.jswith the full state schema. Initialize it with the same values as the current globals. - Implement
event-bus.js. - In
app.js, add bridge code: when a global variable changes, also callstore.setState(...). This keeps both systems in sync during migration. - Verify: app still works identically.
- Move
sse-client.js— extract the SSE parsing logic fromstartAnalysis()andstartGapAnalysis(). - Move
analyze-api.js— extractstartAnalysis()andstartGapAnalysis(). Instead of touching DOM directly, they now callstore.setState()andbus.emit(). - Move
dispatch-api.js— extractdispatchCloudFromGaps(),dispatchLocalFromGaps(),dispatchDeveloperFromGaps(). - Move
deploy-api.js— extractrunDeploy()andrunValidation(). - Update
app.jsto call the new API functions instead of its own copies. Remove the old function bodies, replace with thin wrappers. - Verify: app still works. API calls go through new modules.
RequirementsTable— the biggest extraction. Create the class with mode support. Wire it to the store.- Start with
analyzemode (the most complex — checkboxes, gap enrichment, detail expansion). - Remove
renderRequirementsForSelection(),enrichRowWithGap(),buildQAGapTable(),renderDispatchTable()fromapp.js. - The table instance subscribes to store changes and re-renders automatically.
- Start with
ActivityLog— extractappendLog()andappendToActivityFeed(). Subscribe tolog:entryevents.ProgressStrip— extractmarkStep()andmarkAllStepsDone().MeetingCard— extract meeting card DOM manipulation.QAWorkflow— extractlaunchQAWorkflow(),runDeployOnly(),runValidateOnly()orchestration UI.Toast— extractshowToast().CompletionPanel— extractrenderCompletion().- Verify after each component: app still works.
StageController— extractshowPanel(),setStep(),setActivePhase(),advanceStage(),navigateToPhase(), and theSTAGE_PANEL_MAPlogic. Fix the meet/analyze panel confusion.SlideOverController— extractopenStageDetail(),closeStageDetail(),_returnPanelToMain().HeaderController— extractsetStatus(),showLoopHeader(), phase tab management.- Verify: navigation, slide-over, and stage transitions all work.
LoopRenderer— extractrenderLoopNodes(),renderStageAction(),updateLoopState().LoopParticleSystem— move the existing class as-is to its own file. It's already self-contained.- Verify: infinite loop visualization and particles work.
- Remove all code from
app.js— it should now be empty or contain only thewindow.*shims. - Move
window.*shims tomain.js— these are needed untilonclick="..."attributes in HTML are converted toaddEventListener(). - Delete
app.js. - Update
index.html— remove<script src="app.js">, keep only<script type="module" src="js/main.js">. - Final verification: full app works end-to-end with no
app.js.
- Replace all
onclick="functionName()"attributes in HTML withaddEventListener()calls in the appropriate component constructors. - This removes the need for
window.*global function exports.
- One module extraction at a time. Don't extract two components in parallel.
- After each extraction, the app must work identically. Run through the full flow: landing → enter meeting name → stream requirements → select → analyze gaps → dispatch → deploy → validate.
- Keep
app.jsas a shrinking wrapper during phases 1–5. Functions inapp.jsprogressively become thin delegates to the new modules. - No CSS changes during migration. The new modules use the same class names and DOM structure. CSS refactoring is a separate effort.
- Test the slide-over re-parenting after each panel component is extracted — it's the most fragile part of the architecture.
Every component follows the same pattern:
class MyComponent {
constructor({ store, bus, container }) {
this._container = container;
this._store = store;
this._bus = bus;
this._unsubscribers = [];
// Subscribe to relevant state paths
this._unsubscribers.push(
store.subscribe('some.path', (val) => this._onDataChanged(val))
);
// Subscribe to relevant events
this._unsubscribers.push(
bus.on('some:event', (payload) => this._onEvent(payload))
);
// Initial render
this.render();
}
render() { /* build/update DOM */ }
destroy() {
this._unsubscribers.forEach(unsub => unsub());
this._container.innerHTML = '';
}
}User Action (click, input)
→ Component method
→ api.doSomething() or store.setState(...)
→ Store notifies subscribers
→ Components re-render affected parts
→ DOM updates
No component directly manipulates another component's DOM. All cross-component communication goes through the store or the event bus.
Server SSE Event
→ sse-client parses event
→ api module handler called
→ store.setState({ gaps: [...existing, newGap] })
→ RequirementsTable subscription fires
→ Table renders new/updated row
→ bus.emit('log:entry', { message })
→ ActivityLog renders new log line
-
Shadow DOM — Should components use Shadow DOM for style encapsulation? Recommendation: No. The existing CSS is tightly coupled to class names. Shadow DOM would require duplicating styles or adopting CSS parts. Keep flat DOM for now.
-
Web Components — Should
RequirementsTablebe a Custom Element? Recommendation: Not yet. Plain classes with manual lifecycle are simpler. Can upgrade to Custom Elements later if needed. -
CSS Modules / Scoping — The CSS file is ~4200 lines. A future effort should split it into per-component CSS files using
@importor CSS layers. Out of scope for this refactor. -
TypeScript — The architecture is designed to be TypeScript-ready. JSDoc type annotations in the modules would provide IDE support without requiring a build step. Consider
// @ts-checkat the top of each file. -
Testing — The new module structure makes unit testing possible.
store.jsandevent-bus.jscan be tested in isolation. API modules can be tested with mockedfetch. Components can be tested with JSDOM.