feat: scaffold BuildPulse v0.1 cockpit

This commit is contained in:
OpenClaw Bot
2026-05-07 00:11:35 +02:00
parent 8f0ba43728
commit bdf8773797
18 changed files with 4835 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+22
View File
@@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>buildpulse-vite-template</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2742
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "buildpulse",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.10"
}
}
+980
View File
@@ -0,0 +1,980 @@
import { useEffect, useMemo, useState } from 'react'
import type { ChangeEvent } from 'react'
import './index.css'
import { createMarkdownPackage, createJsonExport, createPulseJsonl } from './features/export/exporters'
import { loadAppState, replaceAppState, saveAppState, validateAppState } from './store/storage'
import { FEATURE_COLUMNS, FEATURE_PRIORITIES, FEATURE_STATUSES, PULSE_TYPES, RISK_LEVELS } from './store/types'
import type { AppState, Feature, FeatureColumn, ParkingLotItem, PulseEvent, RiskLevel, TabKey } from './store/types'
import { arrayToLines, formatDateTime, linesToArray, nowIso, slugify } from './utils/format'
const TABS: Array<{ key: TabKey; label: string }> = [
{ key: 'feature-plan', label: 'Feature Plan' },
{ key: 'parking-lot', label: 'Parking Lot' },
{ key: 'pulse-log', label: 'Pulse Log' },
{ key: 'export', label: 'Export' },
]
const initialFeatureDraft = {
title: '',
description: '',
column: 'now' as FeatureColumn,
priority: 'must' as (typeof FEATURE_PRIORITIES)[number],
status: 'idea' as (typeof FEATURE_STATUSES)[number],
acceptanceCriteria: '',
scopeNotes: '',
}
const initialParkingDraft = {
title: '',
description: '',
reasonParked: '',
futurePlacement: '',
riskLevel: 'medium' as RiskLevel,
}
const initialPulseDraft = {
featureId: '',
source: 'manual',
agentId: 'jimmi',
pulseType: 'INTENT' as (typeof PULSE_TYPES)[number],
message: '',
confidence: '0.9',
evidenceRefs: '',
traceId: '',
structuredPayload: '{}',
}
const columnLabels: Record<FeatureColumn, string> = {
now: 'Now',
next: 'Next',
later: 'Later',
done: 'Done',
}
const downloadText = (filename: string, text: string, contentType = 'text/plain;charset=utf-8') => {
const blob = new Blob([text], { type: contentType })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
URL.revokeObjectURL(url)
}
function App() {
const [appState, setAppState] = useState<AppState>(() => loadAppState())
const [activeTab, setActiveTab] = useState<TabKey>('feature-plan')
const [statusMessage, setStatusMessage] = useState('Seeded with BuildPulse so you can dogfood it immediately.')
const [selectedFeatureId, setSelectedFeatureId] = useState<string | null>(null)
const [selectedParkingId, setSelectedParkingId] = useState<string | null>(null)
const [selectedPulseId, setSelectedPulseId] = useState<string | null>(null)
const [featureDraft, setFeatureDraft] = useState(initialFeatureDraft)
const [parkingDraft, setParkingDraft] = useState(initialParkingDraft)
const [pulseDraft, setPulseDraft] = useState(initialPulseDraft)
const [pulseTypeFilter, setPulseTypeFilter] = useState('all')
const [pulseFeatureFilter, setPulseFeatureFilter] = useState('all')
const [pulseSourceFilter, setPulseSourceFilter] = useState('all')
useEffect(() => {
saveAppState(appState)
}, [appState])
const groupedFeatures = useMemo(
() =>
FEATURE_COLUMNS.reduce<Record<FeatureColumn, Feature[]>>((acc, column) => {
acc[column] = appState.features.filter((feature) => feature.column === column)
return acc
}, { now: [], next: [], later: [], done: [] }),
[appState.features],
)
const selectedFeature = useMemo(
() => appState.features.find((feature) => feature.id === selectedFeatureId) ?? null,
[appState.features, selectedFeatureId],
)
const selectedParkingItem = useMemo(
() => appState.parking_lot.find((item) => item.id === selectedParkingId) ?? null,
[appState.parking_lot, selectedParkingId],
)
const selectedPulse = useMemo(
() => appState.pulses.find((pulse) => pulse.id === selectedPulseId) ?? null,
[appState.pulses, selectedPulseId],
)
const filteredPulses = useMemo(() => {
return [...appState.pulses]
.filter((pulse) => (pulseTypeFilter === 'all' ? true : pulse.pulse_type === pulseTypeFilter))
.filter((pulse) => (pulseFeatureFilter === 'all' ? true : pulse.feature_id === pulseFeatureFilter))
.filter((pulse) => (pulseSourceFilter === 'all' ? true : pulse.source === pulseSourceFilter || pulse.agent_id === pulseSourceFilter))
.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
}, [appState.pulses, pulseFeatureFilter, pulseSourceFilter, pulseTypeFilter])
const uniqueSources = useMemo(
() => Array.from(new Set(appState.pulses.flatMap((pulse) => [pulse.source, pulse.agent_id]).filter(Boolean))).sort(),
[appState.pulses],
)
const markdownPackage = useMemo(() => createMarkdownPackage(appState), [appState])
const updateProject = (field: keyof AppState['project'], value: string) => {
setAppState((current) => ({
...current,
project: {
...current.project,
[field]: value,
updated_at: nowIso(),
},
}))
}
const beginFeatureEdit = (feature: Feature) => {
setSelectedFeatureId(feature.id)
setFeatureDraft({
title: feature.title,
description: feature.description,
column: feature.column,
priority: feature.priority,
status: feature.status,
acceptanceCriteria: arrayToLines(feature.acceptance_criteria),
scopeNotes: feature.scope_notes,
})
}
const resetFeatureDraft = () => {
setSelectedFeatureId(null)
setFeatureDraft(initialFeatureDraft)
}
const saveFeature = () => {
if (!featureDraft.title.trim()) {
setStatusMessage('Feature title is required. Tiny cockpit, tiny guardrails.')
return
}
const timestamp = nowIso()
const acceptanceCriteria = linesToArray(featureDraft.acceptanceCriteria)
if (selectedFeatureId) {
setAppState((current) => ({
...current,
features: current.features.map((feature) =>
feature.id === selectedFeatureId
? {
...feature,
title: featureDraft.title.trim(),
description: featureDraft.description.trim(),
column: featureDraft.column,
priority: featureDraft.priority,
status: featureDraft.status,
acceptance_criteria: acceptanceCriteria,
scope_notes: featureDraft.scopeNotes.trim(),
updated_at: timestamp,
}
: feature,
),
}))
setStatusMessage('Feature updated.')
} else {
const title = featureDraft.title.trim()
const id = `feature_${slugify(title)}_${Date.now().toString(36)}`
setAppState((current) => ({
...current,
features: [
{
id,
title,
description: featureDraft.description.trim(),
column: featureDraft.column,
priority: featureDraft.priority,
status: featureDraft.status,
acceptance_criteria: acceptanceCriteria,
scope_notes: featureDraft.scopeNotes.trim(),
created_at: timestamp,
updated_at: timestamp,
},
...current.features,
],
}))
setStatusMessage(`Feature “${title}” added.`)
}
resetFeatureDraft()
}
const deleteFeature = (featureId: string) => {
setAppState((current) => ({
...current,
features: current.features.filter((feature) => feature.id !== featureId),
}))
if (selectedFeatureId === featureId) resetFeatureDraft()
setStatusMessage('Feature removed. Related pulses stay intact with a graceful missing-feature label.')
}
const quickMoveFeature = (featureId: string, column: FeatureColumn) => {
setAppState((current) => ({
...current,
features: current.features.map((feature) =>
feature.id === featureId ? { ...feature, column, updated_at: nowIso() } : feature,
),
}))
}
const beginParkingEdit = (item: ParkingLotItem) => {
setSelectedParkingId(item.id)
setParkingDraft({
title: item.title,
description: item.description,
reasonParked: item.reason_parked,
futurePlacement: item.possible_future_placement,
riskLevel: item.risk_level,
})
}
const resetParkingDraft = () => {
setSelectedParkingId(null)
setParkingDraft(initialParkingDraft)
}
const saveParkingItem = () => {
if (!parkingDraft.title.trim()) {
setStatusMessage('Parking Lot items need a title.')
return
}
const timestamp = nowIso()
if (selectedParkingId) {
setAppState((current) => ({
...current,
parking_lot: current.parking_lot.map((item) =>
item.id === selectedParkingId
? {
...item,
title: parkingDraft.title.trim(),
description: parkingDraft.description.trim(),
reason_parked: parkingDraft.reasonParked.trim(),
possible_future_placement: parkingDraft.futurePlacement.trim(),
risk_level: parkingDraft.riskLevel,
updated_at: timestamp,
}
: item,
),
}))
setStatusMessage('Parking Lot item updated.')
} else {
const title = parkingDraft.title.trim()
setAppState((current) => ({
...current,
parking_lot: [
{
id: `parked_${slugify(title)}_${Date.now().toString(36)}`,
title,
description: parkingDraft.description.trim(),
reason_parked: parkingDraft.reasonParked.trim(),
possible_future_placement: parkingDraft.futurePlacement.trim(),
risk_level: parkingDraft.riskLevel,
created_at: timestamp,
updated_at: timestamp,
},
...current.parking_lot,
],
}))
setStatusMessage(`Parked “${title}” safely.`)
}
resetParkingDraft()
}
const deleteParkingItem = (itemId: string) => {
setAppState((current) => ({
...current,
parking_lot: current.parking_lot.filter((item) => item.id !== itemId),
}))
if (selectedParkingId === itemId) resetParkingDraft()
setStatusMessage('Parking Lot item removed.')
}
const convertParkingItemToFeature = (item: ParkingLotItem) => {
const timestamp = nowIso()
setAppState((current) => ({
...current,
features: [
{
id: `feature_${slugify(item.title)}_${Date.now().toString(36)}`,
title: item.title,
description: item.description,
column: 'next',
priority: 'could',
status: 'idea',
acceptance_criteria: [],
scope_notes: `Converted from Parking Lot. Original reason parked: ${item.reason_parked}`,
created_at: timestamp,
updated_at: timestamp,
},
...current.features,
],
parking_lot: current.parking_lot.filter((entry) => entry.id !== item.id),
pulses: [
{
id: `pulse_${Date.now().toString(36)}`,
timestamp,
project_id: current.project.id,
source: 'manual',
agent_id: current.settings.default_agent_id,
pulse_type: 'PARKED_IDEA',
message: `Converted parked idea “${item.title}” into a Next feature.`,
structured_payload: {},
confidence_score: 0.8,
evidence_refs: ['Converted from Parking Lot'],
},
...current.pulses,
],
}))
setStatusMessage(`Converted “${item.title}” into a feature.`)
if (selectedParkingId === item.id) resetParkingDraft()
}
const beginPulseEdit = (pulse: PulseEvent) => {
setSelectedPulseId(pulse.id)
setPulseDraft({
featureId: pulse.feature_id ?? '',
source: pulse.source,
agentId: pulse.agent_id,
pulseType: pulse.pulse_type,
message: pulse.message,
confidence: String(pulse.confidence_score),
evidenceRefs: arrayToLines(pulse.evidence_refs),
traceId: pulse.trace_id ?? '',
structuredPayload: JSON.stringify(pulse.structured_payload ?? {}, null, 2),
})
}
const resetPulseDraft = () => {
setSelectedPulseId(null)
setPulseDraft(initialPulseDraft)
}
const savePulse = () => {
if (!pulseDraft.message.trim()) {
setStatusMessage('Pulse message is required.')
return
}
let parsedPayload: Record<string, unknown>
try {
parsedPayload = pulseDraft.structuredPayload.trim() ? JSON.parse(pulseDraft.structuredPayload) : {}
} catch {
setStatusMessage('Structured payload must be valid JSON.')
return
}
const timestamp = nowIso()
const pulse: PulseEvent = {
id: selectedPulseId ?? `pulse_${Date.now().toString(36)}`,
timestamp: selectedPulse?.timestamp ?? timestamp,
project_id: appState.project.id,
feature_id: pulseDraft.featureId || undefined,
source: pulseDraft.source.trim() || 'manual',
agent_id: pulseDraft.agentId.trim() || appState.settings.default_agent_id,
pulse_type: pulseDraft.pulseType,
message: pulseDraft.message.trim(),
structured_payload: parsedPayload,
confidence_score: Number(pulseDraft.confidence) || 0,
evidence_refs: linesToArray(pulseDraft.evidenceRefs),
trace_id: pulseDraft.traceId.trim() || undefined,
}
if (selectedPulseId) {
setAppState((current) => ({
...current,
pulses: current.pulses.map((entry) => (entry.id === selectedPulseId ? pulse : entry)),
}))
setStatusMessage('Pulse updated.')
} else {
setAppState((current) => ({
...current,
pulses: [pulse, ...current.pulses],
}))
setStatusMessage('Pulse added.')
}
resetPulseDraft()
}
const deletePulse = (pulseId: string) => {
setAppState((current) => ({
...current,
pulses: current.pulses.filter((pulse) => pulse.id !== pulseId),
}))
if (selectedPulseId === pulseId) resetPulseDraft()
setStatusMessage('Pulse deleted.')
}
const copyMarkdown = async (filename: string) => {
try {
await navigator.clipboard.writeText(markdownPackage[filename as keyof typeof markdownPackage])
setStatusMessage(`${filename} copied to clipboard.`)
} catch {
setStatusMessage('Clipboard copy failed. Browser said no.')
}
}
const handleImport = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
try {
const text = await file.text()
const parsed = JSON.parse(text)
if (!validateAppState(parsed)) {
setStatusMessage('Import failed: invalid BuildPulse schema or unsupported version.')
return
}
setAppState(replaceAppState(parsed))
setStatusMessage('Import complete. State replaced cleanly.')
} catch {
setStatusMessage('Import failed: invalid JSON.')
} finally {
event.target.value = ''
}
}
const currentFeatureCount = groupedFeatures.now.length
const recentPulsePreview = [...appState.pulses].sort((a, b) => b.timestamp.localeCompare(a.timestamp)).slice(0, 3)
return (
<div className="app-shell">
<header className="hero-card">
<div>
<p className="eyebrow">BuildPulse v0.1</p>
<h1>{appState.project.name}</h1>
<p className="hero-copy">{appState.project.one_line_pitch}</p>
<p className="hero-goal">
<strong>Current goal:</strong> {appState.project.current_goal || 'Set a goal so the cockpit has a heading.'}
</p>
</div>
<div className="hero-stats">
<div>
<span>Now</span>
<strong>{currentFeatureCount}</strong>
</div>
<div>
<span>Parked</span>
<strong>{appState.parking_lot.length}</strong>
</div>
<div>
<span>Pulses</span>
<strong>{appState.pulses.length}</strong>
</div>
</div>
</header>
<section className="project-card card">
<div className="section-heading compact">
<div>
<h2>Project Summary</h2>
<p>Keep the mission clear without turning this into enterprise theater.</p>
</div>
</div>
<div className="form-grid project-grid">
<label>
Project name
<input value={appState.project.name} onChange={(event) => updateProject('name', event.target.value)} />
</label>
<label>
One-line pitch
<input
value={appState.project.one_line_pitch}
onChange={(event) => updateProject('one_line_pitch', event.target.value)}
/>
</label>
<label className="full-span">
Description
<textarea
rows={3}
value={appState.project.description}
onChange={(event) => updateProject('description', event.target.value)}
/>
</label>
<label className="full-span">
Current goal
<input value={appState.project.current_goal} onChange={(event) => updateProject('current_goal', event.target.value)} />
</label>
<label className="full-span">
Notes
<textarea rows={3} value={appState.project.notes} onChange={(event) => updateProject('notes', event.target.value)} />
</label>
</div>
</section>
<nav className="tab-bar" aria-label="Main views">
{TABS.map((tab) => (
<button
key={tab.key}
type="button"
className={tab.key === activeTab ? 'tab active' : 'tab'}
onClick={() => setActiveTab(tab.key)}
>
{tab.label}
</button>
))}
</nav>
<div className="quick-actions card">
<button type="button" onClick={() => { setActiveTab('feature-plan'); resetFeatureDraft() }}>
Add Feature
</button>
<button type="button" onClick={() => { setActiveTab('parking-lot'); resetParkingDraft() }}>
Park Idea
</button>
<button type="button" onClick={() => { setActiveTab('pulse-log'); resetPulseDraft() }}>
Add Pulse
</button>
<button type="button" onClick={() => setActiveTab('export')}>
Export
</button>
</div>
{activeTab === 'feature-plan' && (
<section className="view-stack">
<div className="section-heading">
<div>
<h2>Feature Plan</h2>
<p>Lead with focus, not overview. This is the calm what now? screen.</p>
</div>
</div>
<div className="board-grid">
{FEATURE_COLUMNS.map((column) => (
<article key={column} className="column card">
<div className="column-header">
<div>
<h3>{columnLabels[column]}</h3>
<p>{groupedFeatures[column].length} feature{groupedFeatures[column].length === 1 ? '' : 's'}</p>
</div>
</div>
<div className="column-body">
{groupedFeatures[column].length ? (
groupedFeatures[column].map((feature) => (
<button key={feature.id} type="button" className="item-card feature-card" onClick={() => beginFeatureEdit(feature)}>
<div className="item-card-header">
<strong>{feature.title}</strong>
<span className={`pill ${feature.priority}`}>{feature.priority}</span>
</div>
<p>{feature.description || 'No description yet.'}</p>
<div className="meta-row">
<span>{feature.acceptance_criteria.length} criteria</span>
<label>
<span className="sr-only">Move feature</span>
<select
value={feature.column}
onChange={(event) => {
event.stopPropagation()
quickMoveFeature(feature.id, event.target.value as FeatureColumn)
}}
>
{FEATURE_COLUMNS.map((entry) => (
<option key={entry} value={entry}>
{columnLabels[entry]}
</option>
))}
</select>
</label>
</div>
</button>
))
) : (
<div className="empty-state">No features here yet.</div>
)}
</div>
</article>
))}
</div>
<div className="editor-grid">
<section className="card editor-card">
<div className="section-heading compact">
<div>
<h3>{selectedFeature ? 'Edit Feature' : 'Add Feature'}</h3>
<p>Keep it small, clear, and testable.</p>
</div>
</div>
<div className="form-grid">
<label>
Title
<input value={featureDraft.title} onChange={(event) => setFeatureDraft((current) => ({ ...current, title: event.target.value }))} />
</label>
<label>
Column
<select value={featureDraft.column} onChange={(event) => setFeatureDraft((current) => ({ ...current, column: event.target.value as FeatureColumn }))}>
{FEATURE_COLUMNS.map((column) => (
<option key={column} value={column}>
{columnLabels[column]}
</option>
))}
</select>
</label>
<label>
Priority
<select value={featureDraft.priority} onChange={(event) => setFeatureDraft((current) => ({ ...current, priority: event.target.value as (typeof FEATURE_PRIORITIES)[number] }))}>
{FEATURE_PRIORITIES.map((priority) => (
<option key={priority} value={priority}>
{priority}
</option>
))}
</select>
</label>
<label>
Status
<select value={featureDraft.status} onChange={(event) => setFeatureDraft((current) => ({ ...current, status: event.target.value as (typeof FEATURE_STATUSES)[number] }))}>
{FEATURE_STATUSES.map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
</label>
<label className="full-span">
Description
<textarea rows={3} value={featureDraft.description} onChange={(event) => setFeatureDraft((current) => ({ ...current, description: event.target.value }))} />
</label>
<label className="full-span">
Acceptance criteria (one per line)
<textarea rows={5} value={featureDraft.acceptanceCriteria} onChange={(event) => setFeatureDraft((current) => ({ ...current, acceptanceCriteria: event.target.value }))} />
</label>
<label className="full-span">
Scope notes
<textarea rows={3} value={featureDraft.scopeNotes} onChange={(event) => setFeatureDraft((current) => ({ ...current, scopeNotes: event.target.value }))} />
</label>
</div>
<div className="button-row">
<button type="button" onClick={saveFeature}>{selectedFeature ? 'Save Changes' : 'Add Feature'}</button>
<button type="button" className="ghost" onClick={resetFeatureDraft}>Clear</button>
{selectedFeature && (
<button type="button" className="danger" onClick={() => deleteFeature(selectedFeature.id)}>
Delete Feature
</button>
)}
</div>
</section>
<section className="card preview-card">
<div className="section-heading compact">
<div>
<h3>Recent Pulse</h3>
<p>Just enough movement to stay grounded.</p>
</div>
</div>
{recentPulsePreview.length ? (
<div className="list-stack">
{recentPulsePreview.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>
) : (
<div className="empty-state">No pulses yet. Add an INTENT or DECISION to start the log.</div>
)}
</section>
</div>
</section>
)}
{activeTab === 'parking-lot' && (
<section className="view-stack">
<div className="section-heading">
<div>
<h2>Parking Lot</h2>
<p>This is where useful distractions go so they do not hijack the build.</p>
</div>
</div>
<div className="editor-grid">
<section className="card editor-card">
<div className="section-heading compact">
<div>
<h3>{selectedParkingItem ? 'Edit Parked Idea' : 'Park a New Idea'}</h3>
<p>Capture it cleanly, then get back to the real work.</p>
</div>
</div>
<div className="form-grid">
<label>
Title
<input value={parkingDraft.title} onChange={(event) => setParkingDraft((current) => ({ ...current, title: event.target.value }))} />
</label>
<label>
Risk level
<select value={parkingDraft.riskLevel} onChange={(event) => setParkingDraft((current) => ({ ...current, riskLevel: event.target.value as RiskLevel }))}>
{RISK_LEVELS.map((level) => (
<option key={level} value={level}>
{level}
</option>
))}
</select>
</label>
<label className="full-span">
Description
<textarea rows={3} value={parkingDraft.description} onChange={(event) => setParkingDraft((current) => ({ ...current, description: event.target.value }))} />
</label>
<label className="full-span">
Reason parked
<textarea rows={3} value={parkingDraft.reasonParked} onChange={(event) => setParkingDraft((current) => ({ ...current, reasonParked: event.target.value }))} />
</label>
<label className="full-span">
Possible future placement
<input value={parkingDraft.futurePlacement} onChange={(event) => setParkingDraft((current) => ({ ...current, futurePlacement: event.target.value }))} />
</label>
</div>
<div className="button-row">
<button type="button" onClick={saveParkingItem}>{selectedParkingItem ? 'Save Changes' : 'Park Idea'}</button>
<button type="button" className="ghost" onClick={resetParkingDraft}>Clear</button>
{selectedParkingItem && (
<>
<button type="button" className="ghost" onClick={() => convertParkingItemToFeature(selectedParkingItem)}>
Convert to Feature
</button>
<button type="button" className="danger" onClick={() => deleteParkingItem(selectedParkingItem.id)}>
Delete
</button>
</>
)}
</div>
</section>
<section className="card list-card">
<div className="section-heading compact">
<div>
<h3>Parked Ideas</h3>
<p>Visible enough to trust, quiet enough not to derail you.</p>
</div>
</div>
<div className="list-stack">
{appState.parking_lot.length ? (
appState.parking_lot.map((item) => (
<button key={item.id} type="button" className="item-card parking-card" onClick={() => beginParkingEdit(item)}>
<div className="item-card-header">
<strong>{item.title}</strong>
<span className={`pill risk-${item.risk_level}`}>{item.risk_level}</span>
</div>
<p>{item.description || 'No description yet.'}</p>
<small>{item.reason_parked || 'No reason parked yet.'}</small>
</button>
))
) : (
<div className="empty-state">No parked ideas yet. That probably means you havent had a normal distracted brain day yet.</div>
)}
</div>
</section>
</div>
</section>
)}
{activeTab === 'pulse-log' && (
<section className="view-stack">
<div className="section-heading">
<div>
<h2>Pulse Log</h2>
<p>Manual now, future-compatible later. Keep the history honest.</p>
</div>
</div>
<div className="editor-grid">
<section className="card editor-card">
<div className="section-heading compact">
<div>
<h3>{selectedPulse ? 'Edit Pulse' : 'Add Pulse'}</h3>
<p>Log what happened, not a fantasy backlog status.</p>
</div>
</div>
<div className="form-grid">
<label>
Pulse type
<select value={pulseDraft.pulseType} onChange={(event) => setPulseDraft((current) => ({ ...current, pulseType: event.target.value as (typeof PULSE_TYPES)[number] }))}>
{PULSE_TYPES.map((pulseType) => (
<option key={pulseType} value={pulseType}>
{pulseType}
</option>
))}
</select>
</label>
<label>
Feature link
<select value={pulseDraft.featureId} onChange={(event) => setPulseDraft((current) => ({ ...current, featureId: event.target.value }))}>
<option value="">No linked feature</option>
{appState.features.map((feature) => (
<option key={feature.id} value={feature.id}>
{feature.title}
</option>
))}
</select>
</label>
<label>
Source
<input value={pulseDraft.source} onChange={(event) => setPulseDraft((current) => ({ ...current, source: event.target.value }))} />
</label>
<label>
Agent ID
<input value={pulseDraft.agentId} onChange={(event) => setPulseDraft((current) => ({ ...current, agentId: event.target.value }))} />
</label>
<label>
Confidence
<input value={pulseDraft.confidence} onChange={(event) => setPulseDraft((current) => ({ ...current, confidence: event.target.value }))} />
</label>
<label>
Trace ID
<input value={pulseDraft.traceId} onChange={(event) => setPulseDraft((current) => ({ ...current, traceId: event.target.value }))} />
</label>
<label className="full-span">
Message
<textarea rows={4} value={pulseDraft.message} onChange={(event) => setPulseDraft((current) => ({ ...current, message: event.target.value }))} />
</label>
<label className="full-span">
Evidence refs (one per line)
<textarea rows={4} value={pulseDraft.evidenceRefs} onChange={(event) => setPulseDraft((current) => ({ ...current, evidenceRefs: event.target.value }))} />
</label>
<label className="full-span">
Structured payload JSON
<textarea rows={4} value={pulseDraft.structuredPayload} onChange={(event) => setPulseDraft((current) => ({ ...current, structuredPayload: event.target.value }))} />
</label>
</div>
<div className="button-row">
<button type="button" onClick={savePulse}>{selectedPulse ? 'Save Changes' : 'Add Pulse'}</button>
<button type="button" className="ghost" onClick={resetPulseDraft}>Clear</button>
{selectedPulse && (
<button type="button" className="danger" onClick={() => deletePulse(selectedPulse.id)}>
Delete Pulse
</button>
)}
</div>
</section>
<section className="card list-card">
<div className="section-heading compact filters-heading">
<div>
<h3>Pulse Timeline</h3>
<p>Newest first, with just enough filtering to stay useful.</p>
</div>
<div className="filter-row">
<select value={pulseTypeFilter} onChange={(event) => setPulseTypeFilter(event.target.value)}>
<option value="all">All types</option>
{PULSE_TYPES.map((pulseType) => (
<option key={pulseType} value={pulseType}>
{pulseType}
</option>
))}
</select>
<select value={pulseFeatureFilter} onChange={(event) => setPulseFeatureFilter(event.target.value)}>
<option value="all">All features</option>
{appState.features.map((feature) => (
<option key={feature.id} value={feature.id}>
{feature.title}
</option>
))}
</select>
<select value={pulseSourceFilter} onChange={(event) => setPulseSourceFilter(event.target.value)}>
<option value="all">All sources</option>
{uniqueSources.map((source) => (
<option key={source} value={source}>
{source}
</option>
))}
</select>
</div>
</div>
<div className="list-stack">
{filteredPulses.length ? (
filteredPulses.map((pulse) => {
const linkedFeature = appState.features.find((feature) => feature.id === pulse.feature_id)
return (
<button key={pulse.id} type="button" className="item-card pulse-card" onClick={() => beginPulseEdit(pulse)}>
<div className="item-card-header">
<strong>{pulse.pulse_type}</strong>
<span>{formatDateTime(pulse.timestamp)}</span>
</div>
<p>{pulse.message}</p>
<small>
{linkedFeature?.title ?? (pulse.feature_id ? `${pulse.feature_id} (missing feature)` : 'No linked feature')} · {pulse.source} / {pulse.agent_id}
</small>
</button>
)
})
) : (
<div className="empty-state">No pulses match the current filters.</div>
)}
</div>
</section>
</div>
</section>
)}
{activeTab === 'export' && (
<section className="view-stack">
<div className="section-heading">
<div>
<h2>Export</h2>
<p>Ship clean context packages, not rambling AI bait.</p>
</div>
</div>
<div className="editor-grid">
<section className="card editor-card">
<div className="section-heading compact">
<div>
<h3>Data Exports</h3>
<p>Backups and handoffs first. Fancy multi-file packaging can wait.</p>
</div>
</div>
<div className="button-stack">
<button type="button" onClick={() => downloadText('buildpulse-export.json', createJsonExport(appState), 'application/json;charset=utf-8')}>
Download Full JSON
</button>
<button type="button" onClick={() => downloadText('pulses.jsonl', createPulseJsonl(appState), 'application/x-ndjson;charset=utf-8')}>
Download Pulse JSONL
</button>
<label className="import-label">
Import JSON
<input type="file" accept="application/json" onChange={handleImport} />
</label>
</div>
</section>
<section className="card list-card">
<div className="section-heading compact">
<div>
<h3>Markdown Package</h3>
<p>`CLAUDE_CONTEXT.md` is the decision-boundary file. Keep it sharp.</p>
</div>
</div>
<div className="list-stack markdown-list">
{Object.entries(markdownPackage).map(([filename, content]) => (
<div key={filename} className="markdown-card">
<div className="item-card-header">
<strong>{filename}</strong>
<div className="button-inline-row">
<button type="button" className="ghost small" onClick={() => copyMarkdown(filename)}>
Copy
</button>
<button type="button" className="ghost small" onClick={() => downloadText(filename, content)}>
Download
</button>
</div>
</div>
<pre>{content}</pre>
</div>
))}
</div>
</section>
</div>
</section>
)}
<footer className="status-bar">
<span>{statusMessage}</span>
<span>Stored locally · schema {appState.schema_version}</span>
</footer>
</div>
)
}
export default App
+148
View File
@@ -0,0 +1,148 @@
import type { AppState, Feature, FeatureColumn, PulseEvent } from '../../store/types'
import { formatDateTime } from '../../utils/format'
const columnLabels: Record<FeatureColumn, string> = {
now: 'Now',
next: 'Next',
later: 'Later',
done: 'Done',
}
const groupFeatures = (features: Feature[]) => ({
now: features.filter((feature) => feature.column === 'now'),
next: features.filter((feature) => feature.column === 'next'),
later: features.filter((feature) => feature.column === 'later'),
done: features.filter((feature) => feature.column === 'done'),
})
const getFeatureLabel = (features: Feature[], featureId?: string) => {
if (!featureId) return '—'
return features.find((feature) => feature.id === featureId)?.title ?? `${featureId} (missing feature)`
}
const renderFeature = (feature: Feature) => {
const criteria = feature.acceptance_criteria.length
? feature.acceptance_criteria.map((item) => ` - ${item}`).join('\n')
: ' - None yet'
return [
`- **${feature.title}**`,
` - Description: ${feature.description || '—'}`,
` - Priority: ${feature.priority}`,
` - Status: ${feature.status}`,
' - Acceptance Criteria:',
criteria,
` - Scope Notes: ${feature.scope_notes || '—'}`,
].join('\n')
}
const sortPulsesNewestFirst = (pulses: PulseEvent[]) =>
[...pulses].sort((a, b) => b.timestamp.localeCompare(a.timestamp))
export const createJsonExport = (state: AppState) =>
JSON.stringify(
{
schema_version: state.schema_version,
exported_at: new Date().toISOString(),
project: state.project,
features: state.features,
parking_lot: state.parking_lot,
pulses: state.pulses,
settings: state.settings,
},
null,
2,
)
export const createPulseJsonl = (state: AppState) => state.pulses.map((pulse) => JSON.stringify(pulse)).join('\n')
export const createMarkdownPackage = (state: AppState) => {
const grouped = groupFeatures(state.features)
const recentPulses = sortPulsesNewestFirst(state.pulses).slice(0, 8)
const projectSummary = `# Project Summary\n\n## Name\n${state.project.name}\n\n## One-Line Pitch\n${state.project.one_line_pitch || '—'}\n\n## Description\n${state.project.description || '—'}\n\n## Current Goal\n${state.project.current_goal || '—'}\n\n## Notes\n${state.project.notes || '—'}\n`
const featurePlan = ['# Feature Plan']
;(['now', 'next', 'later', 'done'] as FeatureColumn[]).forEach((column) => {
featurePlan.push(`\n## ${columnLabels[column]}`)
const features = grouped[column]
if (!features.length) {
featurePlan.push('\n_No features in this column yet._')
return
}
featurePlan.push('', ...features.map(renderFeature))
})
const parkingLot = [
'# Parking Lot',
'',
...(state.parking_lot.length
? state.parking_lot.map(
(item) =>
`- **${item.title}**\n - Description: ${item.description || '—'}\n - Reason parked: ${item.reason_parked || '—'}\n - Possible future placement: ${item.possible_future_placement || '—'}\n - Risk level: ${item.risk_level}`,
)
: ['_No parked ideas yet._']),
].join('\n')
const pulseLog = [
'# Pulse Log',
'',
...(sortPulsesNewestFirst(state.pulses).length
? sortPulsesNewestFirst(state.pulses).map(
(pulse) =>
`- **${formatDateTime(pulse.timestamp)}** — ${pulse.pulse_type}\n - Feature: ${getFeatureLabel(state.features, pulse.feature_id)}\n - Source/Agent: ${pulse.source} / ${pulse.agent_id}\n - Message: ${pulse.message}\n - Confidence: ${pulse.confidence_score}\n - Evidence: ${pulse.evidence_refs.length ? pulse.evidence_refs.join('; ') : '—'}`,
)
: ['_No pulse events yet._']),
].join('\n')
const claudeContext = [
'# AI Coding Context',
'',
'## Project',
`${state.project.name}${state.project.one_line_pitch || '—'}`,
'',
'## Current Goal',
state.project.current_goal || '—',
'',
'## Active Features',
grouped.now.length ? grouped.now.map(renderFeature).join('\n\n') : '_None yet._',
'',
'## Next Features',
grouped.next.length ? grouped.next.map(renderFeature).join('\n\n') : '_None yet._',
'',
'## Done Features',
grouped.done.length ? grouped.done.map(renderFeature).join('\n\n') : '_None yet._',
'',
'## Parking Lot / Do Not Implement Yet',
state.parking_lot.length
? state.parking_lot
.map((item) => `- ${item.title}${item.reason_parked || item.description || 'Parked for later.'}`)
.join('\n')
: '_No parked ideas yet._',
'',
'## Recent Pulse Events',
recentPulses.length
? recentPulses
.map(
(pulse) =>
`- ${formatDateTime(pulse.timestamp)}${pulse.pulse_type}${pulse.message} (${getFeatureLabel(state.features, pulse.feature_id)})`,
)
.join('\n')
: '_No recent pulse events yet._',
'',
'## Instructions for AI Developer',
'- Only work on the selected feature.',
'- Do not implement Parking Lot items.',
'- Preserve working behavior.',
'- Report changes and test steps.',
].join('\n')
return {
'PROJECT_SUMMARY.md': projectSummary,
'FEATURE_PLAN.md': featurePlan.join('\n'),
'PARKING_LOT.md': parkingLot,
'PULSE_LOG.md': pulseLog,
'CLAUDE_CONTEXT.md': claudeContext,
}
}
+146
View File
@@ -0,0 +1,146 @@
import type { AppState } from '../../store/types'
const seedDate = '2026-05-06T00:00:00+02:00'
export const createSeedState = (): AppState => ({
schema_version: '0.1.0',
project: {
id: 'project_buildpulse',
name: 'BuildPulse',
one_line_pitch: 'A local-first planning cockpit for AI-assisted product building.',
description:
'BuildPulse helps capture features, park distracting ideas, log progress as Pulse events, and export clean context for AI coding agents.',
current_goal: 'Ship v0.1 with Feature Plan, Parking Lot, Pulse Log, and Export.',
notes: 'First dogfood project: BuildPulse manages BuildPulse.',
created_at: seedDate,
updated_at: seedDate,
},
features: [
{
id: 'feature_plan_screen',
title: 'Feature Plan screen',
description: 'Show the active build plan in calm Now / Next / Later / Done columns.',
column: 'now',
priority: 'must',
status: 'ready',
acceptance_criteria: [
'User can create a feature card.',
'User can move a feature between columns.',
'User can edit feature details without clutter.',
],
scope_notes: 'This is the home screen. It should answer “what now?” immediately.',
created_at: seedDate,
updated_at: seedDate,
},
{
id: 'parking_lot_screen',
title: 'Parking Lot screen',
description: 'Capture useful distractions without letting them hijack the build.',
column: 'now',
priority: 'must',
status: 'ready',
acceptance_criteria: [
'User can add parked ideas quickly.',
'Risk and future placement are visible.',
],
scope_notes: 'Parking is success behavior, not failure.',
created_at: seedDate,
updated_at: seedDate,
},
{
id: 'pulse_log_screen',
title: 'Pulse Log screen',
description: 'Log intent, action, results, blockers, and decisions in a future-compatible pulse shape.',
column: 'now',
priority: 'must',
status: 'ready',
acceptance_criteria: [
'User can add manual pulse events.',
'Pulses can link to features optionally.',
],
scope_notes: 'Manual in v0.1. No live agent ingestion yet.',
created_at: seedDate,
updated_at: seedDate,
},
{
id: 'export_screen',
title: 'Export screen',
description: 'Generate clean JSON, JSONL, and Markdown handoff context for AI developers.',
column: 'now',
priority: 'must',
status: 'ready',
acceptance_criteria: [
'JSON export works.',
'Markdown export includes CLAUDE_CONTEXT.md.',
],
scope_notes: 'Handoff quality matters more than bells and whistles.',
created_at: seedDate,
updated_at: seedDate,
},
],
parking_lot: [
{
id: 'parked_ai_triage',
title: 'AI idea triage',
description: 'Use AI to classify new ideas into Now, Next, Later, Parking Lot, or Reject.',
reason_parked: 'Manual workflow must prove useful first.',
possible_future_placement: 'v0.2',
risk_level: 'medium',
created_at: seedDate,
updated_at: seedDate,
},
{
id: 'parked_multi_project',
title: 'Multi-project support',
description: 'Track several products from one BuildPulse instance.',
reason_parked: 'Single-project discipline is the whole point of v0.1.',
possible_future_placement: 'v0.6+',
risk_level: 'medium',
created_at: seedDate,
updated_at: seedDate,
},
{
id: 'parked_openclaw_integration',
title: 'OpenClaw / Hermes integration',
description: 'Ingest agent events, task output, and status directly into BuildPulse.',
reason_parked: 'Way too spicy for v0.1. Manual pulse logging first.',
possible_future_placement: 'v1.0+',
risk_level: 'high',
created_at: seedDate,
updated_at: seedDate,
},
],
pulses: [
{
id: 'pulse_seed_001',
timestamp: seedDate,
project_id: 'project_buildpulse',
feature_id: 'feature_plan_screen',
source: 'manual',
agent_id: 'jimmi',
pulse_type: 'INTENT',
message: 'Start BuildPulse as a calm single-project cockpit, not a full agent framework.',
structured_payload: {},
confidence_score: 0.95,
evidence_refs: ['docs/PRODUCT_BRIEF.md', 'docs/SCOPE.md'],
trace_id: 'session_seed',
},
{
id: 'pulse_seed_002',
timestamp: seedDate,
project_id: 'project_buildpulse',
source: 'manual',
agent_id: 'jimmi',
pulse_type: 'DECISION',
message: 'Park AI triage, releases, and integrations until the manual workflow proves itself.',
structured_payload: {},
confidence_score: 0.9,
evidence_refs: ['docs/DECISIONS.md', 'docs/PARKING_LOT.md'],
trace_id: 'session_seed',
},
],
settings: {
theme: 'light',
default_agent_id: 'jimmi',
},
})
+456
View File
@@ -0,0 +1,456 @@
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
line-height: 1.5;
font-weight: 400;
color: #e7ecf5;
background:
radial-gradient(circle at top, rgba(122, 162, 247, 0.18), transparent 28%),
linear-gradient(180deg, #0b1020 0%, #0f172a 100%);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
button,
input,
textarea,
select {
font: inherit;
}
button {
cursor: pointer;
}
#root {
min-height: 100vh;
}
.app-shell {
width: min(1320px, calc(100% - 2rem));
margin: 0 auto;
padding: 2rem 0 3rem;
}
.hero-card,
.card {
background: rgba(15, 23, 42, 0.72);
border: 1px solid rgba(148, 163, 184, 0.16);
box-shadow: 0 22px 60px rgba(2, 6, 23, 0.25);
backdrop-filter: blur(16px);
}
.hero-card {
display: flex;
justify-content: space-between;
gap: 1.5rem;
padding: 1.6rem;
border-radius: 28px;
margin-bottom: 1rem;
}
.hero-card h1,
.section-heading h2,
.section-heading h3 {
margin: 0;
}
.eyebrow {
margin: 0 0 0.35rem;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: #9fb5ff;
}
.hero-copy {
margin: 0.6rem 0;
max-width: 52rem;
color: #c9d4ea;
}
.hero-goal {
margin: 0;
color: #dbe7ff;
}
.hero-stats {
display: grid;
grid-template-columns: repeat(3, minmax(90px, 1fr));
gap: 0.75rem;
min-width: 280px;
}
.hero-stats div,
.quick-actions,
.status-bar,
.tab {
border-radius: 18px;
}
.hero-stats div {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
padding: 1rem;
background: rgba(30, 41, 59, 0.72);
}
.hero-stats span,
.meta-row,
small,
.status-bar {
color: #b7c4db;
}
.hero-stats strong {
font-size: 1.8rem;
margin-top: 0.25rem;
}
.project-card,
.quick-actions,
.tab-bar,
.status-bar,
.view-stack,
.editor-grid,
.board-grid {
margin-top: 1rem;
}
.card {
border-radius: 24px;
padding: 1.25rem;
}
.section-heading {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
margin-bottom: 1rem;
}
.section-heading p {
margin: 0.35rem 0 0;
color: #9fb0c9;
}
.section-heading.compact {
margin-bottom: 0.8rem;
}
.project-grid,
.form-grid {
display: grid;
gap: 0.9rem;
}
.project-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.full-span {
grid-column: 1 / -1;
}
label {
display: flex;
flex-direction: column;
gap: 0.35rem;
color: #d9e2f4;
font-size: 0.95rem;
}
input,
textarea,
select {
width: 100%;
border: 1px solid rgba(148, 163, 184, 0.22);
background: rgba(15, 23, 42, 0.9);
color: #f8fbff;
border-radius: 14px;
padding: 0.8rem 0.9rem;
}
textarea {
resize: vertical;
}
.tab-bar {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.tab {
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(15, 23, 42, 0.66);
color: #dbe7ff;
padding: 0.8rem 1rem;
}
.tab.active {
background: linear-gradient(135deg, rgba(96, 165, 250, 0.35), rgba(129, 140, 248, 0.3));
border-color: rgba(129, 140, 248, 0.6);
}
.quick-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
padding: 0.9rem;
}
.button-row,
.button-inline-row,
.button-stack,
.filter-row {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
button,
.import-label {
border: 0;
border-radius: 14px;
background: linear-gradient(135deg, #60a5fa, #818cf8);
color: white;
padding: 0.8rem 1rem;
transition: transform 120ms ease, opacity 120ms ease;
}
button:hover,
.import-label:hover {
transform: translateY(-1px);
}
button.ghost,
.import-label {
background: rgba(30, 41, 59, 0.84);
border: 1px solid rgba(148, 163, 184, 0.16);
}
button.danger {
background: linear-gradient(135deg, #f97316, #ef4444);
}
button.small {
padding: 0.45rem 0.75rem;
}
.import-label {
display: inline-flex;
align-items: center;
justify-content: center;
}
.import-label input {
display: none;
}
.board-grid,
.editor-grid {
display: grid;
gap: 1rem;
}
.board-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.editor-grid {
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
}
.column {
min-height: 360px;
}
.column-header,
.item-card-header,
.meta-row,
.status-bar,
.filters-heading {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
}
.column-header p,
.item-card p,
.item-card small,
.mini-pulse p,
pre {
margin: 0;
}
.column-body,
.list-stack,
.markdown-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.item-card,
.markdown-card,
.empty-state,
.mini-pulse {
width: 100%;
text-align: left;
border: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 18px;
background: rgba(30, 41, 59, 0.58);
color: inherit;
padding: 1rem;
}
.item-card p,
.mini-pulse p,
.empty-state {
color: #c9d4ea;
}
.item-card.feature-card,
.item-card.parking-card,
.item-card.pulse-card {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.item-card select {
min-width: 120px;
padding: 0.5rem 0.7rem;
}
.pill {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
padding: 0.2rem 0.55rem;
font-size: 0.78rem;
text-transform: capitalize;
background: rgba(96, 165, 250, 0.18);
color: #bfdbfe;
}
.pill.must {
background: rgba(96, 165, 250, 0.18);
}
.pill.should {
background: rgba(52, 211, 153, 0.16);
color: #bbf7d0;
}
.pill.could {
background: rgba(250, 204, 21, 0.16);
color: #fde68a;
}
.pill.later,
.pill.risk-medium {
background: rgba(244, 114, 182, 0.16);
color: #fbcfe8;
}
.pill.risk-low {
background: rgba(52, 211, 153, 0.16);
color: #bbf7d0;
}
.pill.risk-high {
background: rgba(251, 146, 60, 0.16);
color: #fed7aa;
}
.pill.risk-dangerous {
background: rgba(248, 113, 113, 0.18);
color: #fecaca;
}
pre {
white-space: pre-wrap;
word-break: break-word;
max-height: 260px;
overflow: auto;
font-size: 0.88rem;
color: #dbe7ff;
}
.status-bar {
padding: 0.95rem 1.1rem;
background: rgba(15, 23, 42, 0.78);
border: 1px solid rgba(148, 163, 184, 0.14);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 1080px) {
.board-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.editor-grid,
.project-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.app-shell {
width: min(100% - 1rem, 100%);
padding: 1rem 0 2rem;
}
.hero-card,
.status-bar {
flex-direction: column;
}
.hero-stats,
.board-grid {
grid-template-columns: 1fr;
}
.quick-actions,
.tab-bar,
.button-row,
.filter-row,
.status-bar {
flex-direction: column;
align-items: stretch;
}
.filters-heading {
flex-direction: column;
align-items: stretch;
}
}
+9
View File
@@ -0,0 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+72
View File
@@ -0,0 +1,72 @@
import { createSeedState } from '../features/project/projectDefaults'
import { FEATURE_COLUMNS, PULSE_TYPES, RISK_LEVELS, SCHEMA_VERSION, STORAGE_KEY } from './types'
import type { AppState } from './types'
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
const hasRequiredStrings = (value: Record<string, unknown>, keys: string[]) =>
keys.every((key) => typeof value[key] === 'string' && value[key])
export const validateAppState = (value: unknown): value is AppState => {
if (!isObject(value)) return false
if (value.schema_version !== SCHEMA_VERSION) return false
if (!isObject(value.project) || !hasRequiredStrings(value.project, ['id', 'name'])) return false
if (!Array.isArray(value.features) || !Array.isArray(value.parking_lot) || !Array.isArray(value.pulses)) return false
if (
value.features.some(
(feature) =>
!isObject(feature) ||
!hasRequiredStrings(feature, ['id', 'title', 'column']) ||
!FEATURE_COLUMNS.includes(feature.column as (typeof FEATURE_COLUMNS)[number]),
)
) {
return false
}
if (
value.parking_lot.some(
(item) =>
!isObject(item) ||
!hasRequiredStrings(item, ['id', 'title']) ||
(typeof item.risk_level === 'string' && !RISK_LEVELS.includes(item.risk_level as (typeof RISK_LEVELS)[number])),
)
) {
return false
}
if (
value.pulses.some(
(pulse) =>
!isObject(pulse) ||
!hasRequiredStrings(pulse, ['id', 'timestamp', 'project_id', 'pulse_type', 'message']) ||
!PULSE_TYPES.includes(pulse.pulse_type as (typeof PULSE_TYPES)[number]),
)
) {
return false
}
return true
}
export const loadAppState = (): AppState => {
if (typeof window === 'undefined') return createSeedState()
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return createSeedState()
try {
const parsed = JSON.parse(raw)
if (validateAppState(parsed)) return parsed
} catch {
// fall through to seed state
}
return createSeedState()
}
export const saveAppState = (state: AppState) => {
if (typeof window === 'undefined') return
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
}
export const replaceAppState = (state: AppState) => {
saveAppState(state)
return state
}
+103
View File
@@ -0,0 +1,103 @@
export const STORAGE_KEY = 'buildpulse.appState.v1'
export const SCHEMA_VERSION = '0.1.0'
export const FEATURE_COLUMNS = ['now', 'next', 'later', 'done'] as const
export type FeatureColumn = (typeof FEATURE_COLUMNS)[number]
export const FEATURE_PRIORITIES = ['must', 'should', 'could', 'later'] as const
export type FeaturePriority = (typeof FEATURE_PRIORITIES)[number]
export const FEATURE_STATUSES = [
'idea',
'shaping',
'ready',
'building',
'testing',
'done',
'parked',
'rejected',
] as const
export type FeatureStatus = (typeof FEATURE_STATUSES)[number]
export const RISK_LEVELS = ['low', 'medium', 'high', 'dangerous'] as const
export type RiskLevel = (typeof RISK_LEVELS)[number]
export const PULSE_TYPES = [
'INTENT',
'ACTION',
'RESULT',
'BLOCKER',
'DECISION',
'PARKED_IDEA',
'TEST_RESULT',
'SESSION_START',
'SESSION_END',
'REFLECTION',
] as const
export type PulseType = (typeof PULSE_TYPES)[number]
export interface Project {
id: string
name: string
one_line_pitch: string
description: string
current_goal: string
notes: string
created_at: string
updated_at: string
}
export interface Feature {
id: string
title: string
description: string
column: FeatureColumn
priority: FeaturePriority
status: FeatureStatus
acceptance_criteria: string[]
scope_notes: string
created_at: string
updated_at: string
}
export interface ParkingLotItem {
id: string
title: string
description: string
reason_parked: string
possible_future_placement: string
risk_level: RiskLevel
created_at: string
updated_at: string
}
export interface PulseEvent {
id: string
timestamp: string
project_id: string
feature_id?: string
source: string
agent_id: string
pulse_type: PulseType
message: string
structured_payload: Record<string, unknown>
confidence_score: number
evidence_refs: string[]
trace_id?: string
}
export interface Settings {
theme: 'light'
default_agent_id: string
}
export interface AppState {
schema_version: string
project: Project
features: Feature[]
parking_lot: ParkingLotItem[]
pulses: PulseEvent[]
settings: Settings
}
export type TabKey = 'feature-plan' | 'parking-lot' | 'pulse-log' | 'export'
+26
View File
@@ -0,0 +1,26 @@
export const nowIso = () => new Date().toISOString()
export const formatDateTime = (value: string) => {
try {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value))
} catch {
return value
}
}
export const linesToArray = (value: string) =>
value
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
export const arrayToLines = (value: string[]) => value.join('\n')
export const slugify = (value: string) =>
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '') || 'item'
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})