feat: add night shift focus panel and unified runtime

This commit is contained in:
OpenClaw Bot
2026-05-09 07:21:10 +02:00
parent 6f7fcd43a5
commit f3e6106f49
5 changed files with 155 additions and 4 deletions
+8
View File
@@ -3,6 +3,9 @@
BuildPulse is a calm planning cockpit for AI-assisted product building. BuildPulse is a calm planning cockpit for AI-assisted product building.
It helps capture features, park distracting ideas, log progress as Pulse events, and export clean project context for AI coding agents such as Claude Code, Codex, OpenCode, OpenClaw, or future autonomous agents. It helps capture features, park distracting ideas, log progress as Pulse events, and export clean project context for AI coding agents such as Claude Code, Codex, OpenCode, OpenClaw, or future autonomous agents.
Personal runtime target:
- `build.friborg.uk`
## Core Idea ## Core Idea
BuildPulse v0.1 is intentionally small. BuildPulse v0.1 is intentionally small.
@@ -95,3 +98,8 @@ After that, BuildPulse should be used to plan and manage projects such as:
Ship the smallest useful version first. Ship the smallest useful version first.
Do not build the full Agent Pulse framework yet. Do not build the full Agent Pulse framework yet.
Do not add AI automation until manual workflows are proven useful. Do not add AI automation until manual workflows are proven useful.
## Local runtime
- UI + API combined server: `http://127.0.0.1:3034`
- API health: `http://127.0.0.1:3034/api/health`
+2 -1
View File
@@ -4,7 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>buildpulse-vite-template</title> <meta name="theme-color" content="#0b1020" />
<title>Build — Night Shift cockpit</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+15 -3
View File
@@ -1,8 +1,14 @@
import express from 'express' import express from 'express'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { checkBackendHealth, fetchStoredAppState, persistAppState } from './appwriteBackend.mjs' import { checkBackendHealth, fetchStoredAppState, persistAppState } from './appwriteBackend.mjs'
const app = express() const app = express()
const port = Number(process.env.BUILDPULSE_API_PORT || 8788) const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const distDir = path.resolve(__dirname, '../dist')
const port = Number(process.env.BUILDPULSE_PORT || process.env.BUILDPULSE_API_PORT || 3034)
const host = process.env.BUILDPULSE_HOST || '127.0.0.1'
app.use(express.json({ limit: '2mb' })) app.use(express.json({ limit: '2mb' }))
@@ -39,6 +45,12 @@ app.put('/api/state', async (req, res) => {
} }
}) })
app.listen(port, () => { app.use(express.static(distDir))
console.log(`BuildPulse API listening on http://127.0.0.1:${port}`)
app.get('/{*path}', (_req, res) => {
res.sendFile(path.join(distDir, 'index.html'))
})
app.listen(port, host, () => {
console.log(`BuildPulse listening on http://${host}:${port}`)
}) })
+58
View File
@@ -157,6 +157,15 @@ function App() {
[appState.pulses, selectedPulseId], [appState.pulses, selectedPulseId],
) )
const selectedFeaturePulses = useMemo(() => {
if (!selectedFeature) return []
return [...appState.pulses]
.filter((pulse) => pulse.feature_id === selectedFeature.id)
.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
.slice(0, 4)
}, [appState.pulses, selectedFeature])
const filteredPulses = useMemo(() => { const filteredPulses = useMemo(() => {
return [...appState.pulses] return [...appState.pulses]
.filter((pulse) => (pulseTypeFilter === 'all' ? true : pulse.pulse_type === pulseTypeFilter)) .filter((pulse) => (pulseTypeFilter === 'all' ? true : pulse.pulse_type === pulseTypeFilter))
@@ -624,6 +633,55 @@ function App() {
</section> </section>
)} )}
{selectedFeature && (
<section className="card focus-card">
<div className="section-heading compact">
<div>
<p className="eyebrow">Night Shift Focus</p>
<h3>{selectedFeature.title}</h3>
<p>The current build thread, stripped of excuses and fog.</p>
</div>
<div className="focus-badges">
<span className={`pill ${selectedFeature.priority}`}>{selectedFeature.priority}</span>
<span className="pill">{selectedFeature.status}</span>
<span className="pill">{columnLabels[selectedFeature.column]}</span>
</div>
</div>
<div className="focus-grid">
<article className="focus-panel">
<h4>Acceptance Criteria</h4>
{selectedFeature.acceptance_criteria.length ? (
<ul>
{selectedFeature.acceptance_criteria.map((criterion) => (
<li key={criterion}>{criterion}</li>
))}
</ul>
) : (
<p>No criteria yet. A dangerous little vacuum.</p>
)}
</article>
<article className="focus-panel">
<h4>Scope Notes</h4>
<p>{selectedFeature.scope_notes || 'No scope notes yet. Add the edges before the feature sprawls.'}</p>
<h4>Recent Pulse</h4>
{selectedFeaturePulses.length ? (
<div className="list-stack compact-stack">
{selectedFeaturePulses.map((pulse) => (
<div key={pulse.id} className="mini-pulse">
<strong>{pulse.pulse_type}</strong>
<span>{formatDateTime(pulse.timestamp)}</span>
<p>{pulse.message}</p>
</div>
))}
</div>
) : (
<p>No feature-linked pulses yet. Log intent before the night gets blurry.</p>
)}
</article>
</div>
</section>
)}
<div className="board-grid"> <div className="board-grid">
{FEATURE_COLUMNS.map((column) => ( {FEATURE_COLUMNS.map((column) => (
<article key={column} className="column card"> <article key={column} className="column card">
+72
View File
@@ -51,6 +51,18 @@ button {
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
} }
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
background:
radial-gradient(circle at 15% 20%, rgba(96, 165, 250, 0.14), transparent 18%),
radial-gradient(circle at 85% 12%, rgba(168, 85, 247, 0.12), transparent 20%),
radial-gradient(circle at 50% 80%, rgba(45, 212, 191, 0.08), transparent 24%);
opacity: 0.9;
}
.hero-card { .hero-card {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -122,6 +134,7 @@ small,
.project-card, .project-card,
.quick-actions, .quick-actions,
.focus-card,
.tab-bar, .tab-bar,
.status-bar, .status-bar,
.view-stack, .view-stack,
@@ -214,6 +227,64 @@ textarea {
padding: 0.9rem; padding: 0.9rem;
} }
.focus-card {
position: relative;
overflow: hidden;
}
.focus-card::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(96, 165, 250, 0.08), rgba(45, 212, 191, 0.02) 48%, transparent 75%);
pointer-events: none;
}
.focus-grid,
.focus-badges,
.compact-stack {
position: relative;
z-index: 1;
}
.focus-grid {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 1rem;
}
.focus-panel {
border-radius: 20px;
border: 1px solid rgba(148, 163, 184, 0.14);
background: rgba(15, 23, 42, 0.54);
padding: 1rem;
}
.focus-panel h4 {
margin: 0 0 0.65rem;
}
.focus-panel h4 + p,
.focus-panel ul,
.focus-panel p {
margin-top: 0;
}
.focus-panel ul {
padding-left: 1.1rem;
color: #dbe7ff;
}
.focus-badges {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.compact-stack {
gap: 0.6rem;
}
.button-row, .button-row,
.button-inline-row, .button-inline-row,
.button-stack, .button-stack,
@@ -418,6 +489,7 @@ pre {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.focus-grid,
.editor-grid, .editor-grid,
.project-grid { .project-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;