@sienklogic/plan-build-run 2.27.2 → 2.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,42 +1,56 @@
1
1
  /* ============================================
2
- Towline Dashboard — Design Tokens
3
- Single source of truth for colors, spacing,
4
- typography, and sizing across all CSS files.
2
+ PBR Dashboard — Design Tokens
3
+ Semantic CSS variables backed by Open Props.
4
+ Open Props must be loaded before this file.
5
5
  ============================================ */
6
6
 
7
7
  /* --- Light Mode (Default) --- */
8
8
  :root {
9
- /* Colors */
10
- --color-surface: #ffffff;
11
- --color-surface-raised: #f8f9fa;
12
- --color-surface-hover: rgba(0, 0, 0, 0.04);
13
- --color-border: rgba(0, 0, 0, 0.1);
14
- --color-text-dim: rgba(0, 0, 0, 0.5);
15
- --color-text: #1a1a2e;
16
- --color-accent: var(--tblr-primary, #0054a6);
17
-
18
- /* Spacing */
19
- --space-xs: 0.25rem;
20
- --space-sm: 0.5rem;
21
- --space-md: 1rem;
22
- --space-lg: 1.5rem;
23
- --space-xl: 2rem;
24
- --space-2xl: 3rem;
25
-
26
- /* Radii */
27
- --radius-sm: 0.375rem;
28
- --radius-md: 0.5rem;
29
- --radius-lg: 0.75rem;
9
+ /* Surface colors */
10
+ --color-surface: var(--gray-0);
11
+ --color-surface-raised: var(--gray-1);
12
+ --color-surface-hover: var(--gray-2);
13
+ --color-border: var(--gray-3);
14
+
15
+ /* Text colors */
16
+ --color-text: var(--gray-9);
17
+ --color-text-dim: var(--gray-6);
18
+ --color-text-muted: var(--gray-5);
19
+
20
+ /* Accent */
21
+ --color-accent: var(--blue-6);
22
+ --color-accent-hover: var(--blue-7);
23
+
24
+ /* Spacing — mapped from Open Props size scale */
25
+ --space-xs: var(--size-1);
26
+ --space-sm: var(--size-2);
27
+ --space-md: var(--size-4);
28
+ --space-lg: var(--size-6);
29
+ --space-xl: var(--size-8);
30
+ --space-2xl: var(--size-10);
31
+
32
+ /* Border radii */
33
+ --radius-sm: var(--radius-2);
34
+ --radius-md: var(--radius-3);
35
+ --radius-lg: var(--radius-4);
30
36
 
31
37
  /* Shadows */
32
- --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
33
- --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.06);
38
+ --shadow-sm: var(--shadow-2);
39
+ --shadow-md: var(--shadow-4);
40
+
41
+ /* Typography */
42
+ --font-sans: var(--font-sans);
43
+ --font-mono: var(--font-mono);
44
+
45
+ /* Layout dimensions */
46
+ --size-sidebar: 220px;
47
+ --size-header: 3.5rem;
34
48
 
35
49
  /* Transitions */
36
- --transition-fast: 0.12s ease;
37
- --transition-base: 0.2s ease;
50
+ --transition-fast: var(--ease-3) 0.12s;
51
+ --transition-base: var(--ease-3) 0.2s;
38
52
 
39
- /* Cards */
53
+ /* Component tokens — derived from semantic tokens above */
40
54
  --card-bg: var(--color-surface-raised);
41
55
  --card-border: var(--color-border);
42
56
  --card-radius: var(--radius-md);
@@ -44,52 +58,44 @@
44
58
  --card-padding: var(--space-md);
45
59
  --card-header-padding: var(--space-sm) var(--space-lg);
46
60
 
47
- /* Tables */
48
61
  --table-cell-padding: var(--space-xs) var(--space-sm);
49
- --table-cell-padding-y: var(--space-xs);
50
- --table-cell-padding-x: var(--space-sm);
51
62
 
52
- /* Badges */
53
63
  --badge-padding-sm: 0.1rem 0.4rem;
54
64
  --badge-padding-base: 0.15rem 0.55rem;
55
65
  --badge-padding-lg: 0.25rem 0.75rem;
56
66
  --badge-font-size-sm: 0.6875rem;
57
67
  --badge-font-size-base: 0.75rem;
58
68
  --badge-font-size-lg: 0.8125rem;
69
+ }
59
70
 
60
- /* Typography */
61
- --font-sans: 'Inter', system-ui, -apple-system, sans-serif;
62
- --font-mono: 'JetBrains Mono', ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
63
-
64
- /* Overlays & Modals */
65
- --overlay-bg: rgba(0, 0, 0, 0.3);
66
- --overlay-bg-heavy: rgba(0, 0, 0, 0.7);
67
- --color-dark-surface: #1a1a2e;
68
- --transition-transform: transform 0.25s ease;
71
+ /* --- Dark Mode (explicit [data-theme="dark"] attribute) --- */
72
+ [data-theme="dark"] {
73
+ --color-surface: var(--gray-9);
74
+ --color-surface-raised: var(--gray-8);
75
+ --color-surface-hover: var(--gray-7);
76
+ --color-border: var(--gray-7);
69
77
 
70
- /* Sizing */
71
- --size-sidebar: 220px;
72
- --size-header: 3.5rem;
73
- }
78
+ --color-text: var(--gray-0);
79
+ --color-text-dim: var(--gray-4);
80
+ --color-text-muted: var(--gray-5);
74
81
 
75
- /* --- Dark Mode (explicit attribute) --- */
76
- [data-bs-theme="dark"] {
77
- --color-surface: #13131a;
78
- --color-surface-raised: rgba(255, 255, 255, 0.03);
79
- --color-surface-hover: rgba(255, 255, 255, 0.06);
80
- --color-border: rgba(255, 255, 255, 0.08);
81
- --color-text-dim: rgba(255, 255, 255, 0.5);
82
- --color-text: #e8e8f0;
82
+ --color-accent: var(--blue-4);
83
+ --color-accent-hover: var(--blue-3);
83
84
  }
84
85
 
85
86
  /* --- Dark Mode (system preference, unless light forced) --- */
86
87
  @media (prefers-color-scheme: dark) {
87
- :root:not([data-bs-theme="light"]) {
88
- --color-surface: #13131a;
89
- --color-surface-raised: rgba(255, 255, 255, 0.03);
90
- --color-surface-hover: rgba(255, 255, 255, 0.06);
91
- --color-border: rgba(255, 255, 255, 0.08);
92
- --color-text-dim: rgba(255, 255, 255, 0.5);
93
- --color-text: #e8e8f0;
88
+ :root:not([data-theme="light"]) {
89
+ --color-surface: var(--gray-9);
90
+ --color-surface-raised: var(--gray-8);
91
+ --color-surface-hover: var(--gray-7);
92
+ --color-border: var(--gray-7);
93
+
94
+ --color-text: var(--gray-0);
95
+ --color-text-dim: var(--gray-4);
96
+ --color-text-muted: var(--gray-5);
97
+
98
+ --color-accent: var(--blue-4);
99
+ --color-accent-hover: var(--blue-3);
94
100
  }
95
101
  }
@@ -1,100 +1,70 @@
1
1
  /**
2
- * Custom SSE client with exponential backoff reconnection and state recovery.
2
+ * SSE client with exponential backoff reconnection.
3
+ * Listens for file-change events and triggers HTMX refresh.
3
4
  */
4
5
  (function () {
5
6
  'use strict';
6
7
 
7
- class SSEClient {
8
- constructor(url) {
9
- this.baseUrl = url;
10
- this.lastEventId = null;
11
- this.backoff = 1000;
12
- this.maxBackoff = 30000;
13
- this.reconnectTimer = null;
14
- this.es = null;
15
- this.connect();
16
- }
17
-
18
- connect() {
19
- if (this.es) {
20
- this.es.close();
21
- }
8
+ var SSE_URL = '/api/events/stream';
9
+ var backoff = 1000;
10
+ var maxBackoff = 30000;
11
+ var reconnectTimer = null;
12
+ var es = null;
22
13
 
23
- let url = this.baseUrl;
24
- if (this.lastEventId) {
25
- const sep = url.includes('?') ? '&' : '?';
26
- url += sep + 'lastEventId=' + encodeURIComponent(this.lastEventId);
27
- }
14
+ function updateStatus(connected) {
15
+ var dot = document.getElementById('sse-status');
16
+ if (dot) {
17
+ dot.setAttribute('data-connected', String(connected));
18
+ dot.setAttribute('title', connected ? 'Live updates: connected' : 'Live updates: disconnected');
19
+ dot.setAttribute('aria-label', connected ? 'Live updates: connected' : 'Live updates: disconnected');
20
+ }
21
+ }
28
22
 
29
- this.es = new EventSource(url);
23
+ function scheduleReconnect() {
24
+ if (reconnectTimer) {
25
+ clearTimeout(reconnectTimer);
26
+ }
27
+ // Add jitter to avoid thundering herd
28
+ var jitter = backoff * (0.5 + Math.random() * 0.5);
29
+ reconnectTimer = setTimeout(connect, jitter);
30
+ backoff = Math.min(backoff * 2, maxBackoff);
31
+ }
30
32
 
31
- this.es.onopen = () => {
32
- this.backoff = 1000;
33
- this.updateStatus(true);
34
- };
33
+ function connect() {
34
+ if (es) {
35
+ es.close();
36
+ es = null;
37
+ }
35
38
 
36
- this.es.onerror = () => {
37
- this.es.close();
38
- this.updateStatus(false);
39
- this.scheduleReconnect();
40
- };
39
+ es = new EventSource(SSE_URL);
41
40
 
42
- this.es.addEventListener('file-change', (e) => {
43
- if (e.lastEventId) {
44
- this.lastEventId = e.lastEventId;
45
- }
46
- this.refreshContent();
47
- });
41
+ es.onopen = function () {
42
+ backoff = 1000; // Reset backoff on successful connection
43
+ updateStatus(true);
44
+ };
48
45
 
49
- this.es.addEventListener('state-recovery', (e) => {
50
- if (e.lastEventId) {
51
- this.lastEventId = e.lastEventId;
52
- }
53
- this.refreshContent();
54
- });
55
- }
46
+ es.onerror = function () {
47
+ es.close();
48
+ es = null;
49
+ updateStatus(false);
50
+ scheduleReconnect();
51
+ };
56
52
 
57
- scheduleReconnect() {
58
- if (this.reconnectTimer) {
59
- clearTimeout(this.reconnectTimer);
53
+ es.addEventListener('file-change', function (event) {
54
+ var data = null;
55
+ try {
56
+ data = JSON.parse(event.data);
57
+ } catch (_e) {
58
+ data = { raw: event.data };
60
59
  }
61
- const jitter = this.backoff * (0.5 + Math.random() * 0.5);
62
- this.reconnectTimer = setTimeout(() => {
63
- this.connect();
64
- }, jitter);
65
- this.backoff = Math.min(this.backoff * 2, this.maxBackoff);
66
- }
67
-
68
- updateStatus(connected) {
69
- const dot = document.getElementById('sse-status');
70
- if (dot) {
71
- dot.setAttribute('data-connected', String(connected));
72
- dot.setAttribute('title', connected ? 'Live updates: connected' : 'Live updates: disconnected');
60
+ // Notify HTMX listeners via custom event
61
+ if (typeof htmx !== 'undefined') {
62
+ htmx.trigger(document.body, 'sse:file-change', data);
73
63
  }
74
- }
75
-
76
- refreshContent() {
77
- const currentPath = window.location.pathname;
78
- fetch(currentPath, {
79
- headers: { 'HX-Request': 'true' }
80
- })
81
- .then((res) => {
82
- if (res.ok) return res.text();
83
- throw new Error('Fetch failed: ' + res.status);
84
- })
85
- .then((html) => {
86
- const target = document.getElementById('main-content');
87
- if (target) {
88
- target.innerHTML = html;
89
- }
90
- })
91
- .catch((err) => {
92
- console.error('SSE content refresh failed:', err.message);
93
- });
94
- }
64
+ });
95
65
  }
96
66
 
97
- document.addEventListener('DOMContentLoaded', () => {
98
- new SSEClient('/api/events/stream');
67
+ document.addEventListener('DOMContentLoaded', function () {
68
+ connect();
99
69
  });
100
70
  })();
@@ -1,18 +1,24 @@
1
- /* theme-toggle.js — Toggle light/dark theme via data-bs-theme attribute + localStorage */
2
1
  (function () {
3
2
  'use strict';
4
3
 
5
4
  var STORAGE_KEY = 'pbr-theme';
5
+ var root = document.documentElement;
6
+
7
+ // Apply saved or system preference immediately to prevent flash
8
+ var saved = localStorage.getItem(STORAGE_KEY);
9
+ if (saved) {
10
+ root.setAttribute('data-theme', saved);
11
+ } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
12
+ root.setAttribute('data-theme', 'dark');
13
+ }
6
14
 
7
15
  function getEffectiveTheme() {
8
- var explicit = document.documentElement.dataset.bsTheme;
9
- if (explicit === 'light' || explicit === 'dark') return explicit;
10
- return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
16
+ return root.getAttribute('data-theme') || 'light';
11
17
  }
12
18
 
13
19
  function updateIcon(btn, theme) {
14
- // Show sun when dark (click to go light), moon when light (click to go dark)
15
- btn.textContent = theme === 'dark' ? '\u2600\uFE0F' : '\uD83C\uDF19';
20
+ // Sun when dark (click to go light), moon when light (click to go dark)
21
+ btn.querySelector('.theme-btn__icon').textContent = theme === 'dark' ? '\u2600\uFE0F' : '\uD83C\uDF19';
16
22
  btn.setAttribute('aria-label', theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme');
17
23
  }
18
24
 
@@ -20,25 +26,24 @@
20
26
  var btn = document.getElementById('theme-toggle');
21
27
  if (!btn) return;
22
28
 
23
- // Apply stored theme (also done in layout-top inline script for flash prevention)
24
- var stored = localStorage.getItem(STORAGE_KEY);
25
- if (stored) {
26
- document.documentElement.dataset.bsTheme = stored;
27
- }
28
-
29
+ // Sync icon with current theme
29
30
  updateIcon(btn, getEffectiveTheme());
30
31
 
31
32
  btn.addEventListener('click', function () {
32
33
  var current = getEffectiveTheme();
33
34
  var next = current === 'dark' ? 'light' : 'dark';
34
- document.documentElement.dataset.bsTheme = next;
35
+ root.setAttribute('data-theme', next);
35
36
  localStorage.setItem(STORAGE_KEY, next);
36
37
  updateIcon(btn, next);
37
38
  });
38
39
 
39
- // Update icon if system preference changes and no explicit preference is stored
40
+ // Update icon if system preference changes and no explicit choice is stored
40
41
  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
41
42
  if (!localStorage.getItem(STORAGE_KEY)) {
43
+ root.removeAttribute('data-theme');
44
+ if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
45
+ root.setAttribute('data-theme', 'dark');
46
+ }
42
47
  updateIcon(btn, getEffectiveTheme());
43
48
  }
44
49
  });
@@ -0,0 +1,93 @@
1
+ import { html } from 'hono/html';
2
+
3
+ interface LayoutProps {
4
+ title: string;
5
+ children: any;
6
+ currentView?: string;
7
+ }
8
+
9
+ const navItems = [
10
+ { href: '/', label: 'Command Center', view: 'home' },
11
+ { href: '/explorer', label: 'Explorer', view: 'explorer' },
12
+ { href: '/timeline', label: 'Timeline', view: 'timeline' },
13
+ { href: '/settings', label: 'Settings', view: 'settings' },
14
+ ];
15
+
16
+ export function Layout({ title, children, currentView }: LayoutProps) {
17
+ return (
18
+ <html lang="en" data-theme="light">
19
+ <head>
20
+ <meta charset="UTF-8" />
21
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
22
+ <title>{title} — PBR Dashboard</title>
23
+
24
+ {/* Open Props */}
25
+ <link rel="stylesheet" href="https://unpkg.com/open-props" />
26
+ <link rel="stylesheet" href="https://unpkg.com/open-props/normalize.min.css" />
27
+
28
+ {/* Local design system */}
29
+ <link rel="stylesheet" href="/css/tokens.css" />
30
+ <link rel="stylesheet" href="/css/layout.css" />
31
+ <link rel="stylesheet" href="/css/status-colors.css" />
32
+
33
+ {/* Prevent flash of wrong theme */}
34
+ {html`<script>
35
+ (function() {
36
+ var saved = localStorage.getItem('pbr-theme');
37
+ if (saved) { document.documentElement.setAttribute('data-theme', saved); }
38
+ else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
39
+ document.documentElement.setAttribute('data-theme', 'dark');
40
+ }
41
+ })();
42
+ </script>`}
43
+ </head>
44
+ <body>
45
+ <a href="#main-content" class="skip-link">Skip to content</a>
46
+
47
+ <nav class="sidebar" aria-label="Main navigation">
48
+ <div class="sidebar__brand">
49
+ <span class="sidebar__brand-name">PBR</span>
50
+ <span class="sidebar__brand-subtitle">Dashboard</span>
51
+ </div>
52
+
53
+ <ul class="sidebar__nav" role="list">
54
+ {navItems.map((item) => {
55
+ const isActive = currentView === item.view ||
56
+ (!currentView && item.view === 'home');
57
+ return (
58
+ <li key={item.href}>
59
+ <a
60
+ href={item.href}
61
+ class={`sidebar__nav-link${isActive ? ' sidebar__nav-link--active' : ''}`}
62
+ aria-current={isActive ? 'page' : undefined}
63
+ >
64
+ {item.label}
65
+ </a>
66
+ </li>
67
+ );
68
+ })}
69
+ </ul>
70
+
71
+ <div class="sidebar__footer">
72
+ <button id="theme-toggle" class="theme-btn" type="button" aria-label="Toggle dark/light theme">
73
+ <span class="theme-btn__icon" aria-hidden="true">◐</span>
74
+ </button>
75
+ <span id="sse-status" data-connected="false" title="Live updates: disconnected" aria-label="Live update status"></span>
76
+ </div>
77
+ </nav>
78
+
79
+ <main id="main-content" class="main-content">
80
+ {children}
81
+ </main>
82
+
83
+ {/* HTMX */}
84
+ <script src="https://unpkg.com/htmx.org@2" defer></script>
85
+ {/* Alpine.js */}
86
+ <script src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js" defer></script>
87
+ {/* Local scripts */}
88
+ <script src="/js/theme-toggle.js"></script>
89
+ <script src="/js/sse-client.js" defer></script>
90
+ </body>
91
+ </html>
92
+ );
93
+ }
@@ -0,0 +1,10 @@
1
+ /// <reference types="typed-htmx" />
2
+ import 'hono/jsx';
3
+
4
+ declare module 'hono/jsx' {
5
+ namespace JSX {
6
+ interface IntrinsicElements {
7
+ [key: string]: HtmxAttributes & Record<string, unknown>;
8
+ }
9
+ }
10
+ }
@@ -0,0 +1,132 @@
1
+ import { serve } from '@hono/node-server';
2
+ import { serveStatic } from '@hono/node-server/serve-static';
3
+ import { Hono } from 'hono';
4
+ import { compress } from 'hono/compress';
5
+ import { logger } from 'hono/logger';
6
+ import { secureHeaders } from 'hono/secure-headers';
7
+ import { Layout } from './components/Layout';
8
+ import { indexRouter } from './routes/index.routes';
9
+ import { sseHandler } from './sse-handler';
10
+ import { startWatcher } from './watcher-setup';
11
+ import { currentPhaseMiddleware } from './middleware/current-phase';
12
+
13
+ interface ServerConfig {
14
+ projectDir: string;
15
+ port: number;
16
+ }
17
+
18
+ interface CurrentPhase {
19
+ number: number;
20
+ name: string;
21
+ status: string;
22
+ nextAction: string | null;
23
+ }
24
+
25
+ type Env = {
26
+ Variables: {
27
+ projectDir: string;
28
+ currentPhase: CurrentPhase | null;
29
+ };
30
+ };
31
+
32
+ function createApp(config: ServerConfig) {
33
+ const app = new Hono<Env>();
34
+
35
+ // Inject projectDir into context for all routes
36
+ app.use('*', async (c, next) => {
37
+ c.set('projectDir', config.projectDir);
38
+ await next();
39
+ });
40
+
41
+ // Security headers (replaces helmet)
42
+ app.use('*', secureHeaders());
43
+
44
+ // Compression — skip SSE endpoint to avoid buffering
45
+ app.use('*', async (c, next) => {
46
+ if (c.req.path.startsWith('/api/events')) {
47
+ return next();
48
+ }
49
+ return compress()(c, next);
50
+ });
51
+
52
+ // Request logging
53
+ app.use('*', logger());
54
+
55
+ // Vary: Accept header on all responses
56
+ app.use('*', async (c, next) => {
57
+ await next();
58
+ c.header('Vary', 'Accept');
59
+ });
60
+
61
+ // Static file serving from public/
62
+ app.use('*', serveStatic({ root: './public' }));
63
+
64
+ // Current phase middleware — populates c.var.currentPhase for all routes
65
+ app.use('*', currentPhaseMiddleware);
66
+
67
+ // Routes
68
+ app.route('/', indexRouter);
69
+
70
+ // SSE endpoint — real streamSSE handler with multi-client broadcast
71
+ app.get('/api/events/stream', sseHandler);
72
+
73
+ // 404 handler
74
+ app.notFound((c) => {
75
+ return c.html(
76
+ <Layout title="Not Found">
77
+ <h1>404 — Not Found</h1>
78
+ <p>The page you requested does not exist.</p>
79
+ <p><a href="/">Return to Command Center</a></p>
80
+ </Layout>,
81
+ 404
82
+ );
83
+ });
84
+
85
+ // Error handler
86
+ app.onError((err, c) => {
87
+ console.error('Server error:', err);
88
+ return c.html(
89
+ <Layout title="Server Error">
90
+ <h1>500 — Server Error</h1>
91
+ <p>Something went wrong. Check the server logs for details.</p>
92
+ <p><a href="/">Return to Command Center</a></p>
93
+ </Layout>,
94
+ 500
95
+ );
96
+ });
97
+
98
+ return app;
99
+ }
100
+
101
+ export function startServer(config: ServerConfig): void {
102
+ const app = createApp(config);
103
+
104
+ const server = serve({
105
+ fetch: app.fetch,
106
+ port: config.port,
107
+ });
108
+
109
+ // Start file watcher — broadcasts SSE events on .planning/ changes
110
+ // Import caches lazily to avoid circular deps
111
+ const watcher = startWatcher(config.projectDir, () => {
112
+ // Cache invalidation callback — services with TTL caches expose them
113
+ // These will be wired as services are consumed by routes
114
+ });
115
+
116
+ console.log(`PBR Dashboard running at http://localhost:${config.port}`);
117
+ console.log(`Project directory: ${config.projectDir}`);
118
+
119
+ // Graceful shutdown
120
+ process.on('SIGINT', () => {
121
+ console.log('\nShutting down dashboard...');
122
+ watcher.close();
123
+ server.close(() => process.exit(0));
124
+ });
125
+
126
+ process.on('SIGTERM', () => {
127
+ watcher.close();
128
+ server.close(() => process.exit(0));
129
+ });
130
+ }
131
+
132
+ export { createApp };
@@ -0,0 +1,25 @@
1
+ import type { MiddlewareHandler } from 'hono';
2
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
3
+ // @ts-ignore — dashboard.service.js is plain ESM with no type declarations
4
+ import { parseStateFile } from '../services/dashboard.service.js';
5
+
6
+ export const currentPhaseMiddleware: MiddlewareHandler = async (c, next) => {
7
+ try {
8
+ const projectDir = c.get('projectDir') as string;
9
+ const state = await parseStateFile(projectDir);
10
+ const cp = state.currentPhase;
11
+ if (cp && cp.id > 0) {
12
+ c.set('currentPhase', {
13
+ number: cp.id,
14
+ name: cp.name,
15
+ status: cp.status,
16
+ nextAction: state.nextAction || null,
17
+ });
18
+ } else {
19
+ c.set('currentPhase', null);
20
+ }
21
+ } catch {
22
+ c.set('currentPhase', null);
23
+ }
24
+ await next();
25
+ };
@@ -0,0 +1,36 @@
1
+ import { Hono } from 'hono';
2
+ import { Layout } from '../components/Layout';
3
+
4
+ type Env = {
5
+ Variables: {
6
+ projectDir: string;
7
+ };
8
+ };
9
+
10
+ const router = new Hono<Env>();
11
+
12
+ router.get('/favicon.ico', (c) => {
13
+ return c.body(null, 204);
14
+ });
15
+
16
+ router.get('/', (c) => {
17
+ const isHtmx = c.req.header('HX-Request');
18
+
19
+ if (isHtmx) {
20
+ return c.html(
21
+ <main id="main-content">
22
+ <h1>Command Center</h1>
23
+ <p>Project overview loads here.</p>
24
+ </main>
25
+ );
26
+ }
27
+
28
+ return c.html(
29
+ <Layout title="Command Center" currentView="home">
30
+ <h1>Command Center</h1>
31
+ <p>Project overview loads here.</p>
32
+ </Layout>
33
+ );
34
+ });
35
+
36
+ export { router as indexRouter };