@sienklogic/plan-build-run 2.30.0 → 2.31.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ All notable changes to Plan-Build-Run will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.31.0](https://github.com/SienkLogic/plan-build-run/compare/plan-build-run-v2.30.0...plan-build-run-v2.31.0) (2026-02-24)
9
+
10
+
11
+ ### Features
12
+
13
+ * **41-01:** create timeline routes, wire into app, link timeline CSS in Layout ([ffc71c6](https://github.com/SienkLogic/plan-build-run/commit/ffc71c6ff32f72cf09894585e96cfd47d9ebad93))
14
+ * **41-01:** create timeline.service.js with event aggregation and filtering ([f585c2b](https://github.com/SienkLogic/plan-build-run/commit/f585c2b28a35ca028a0dfd6fb98fc3f717b0e42d))
15
+ * **41-01:** create TimelinePage component, EventStreamFragment, and timeline CSS ([105b3d3](https://github.com/SienkLogic/plan-build-run/commit/105b3d3e1fc3ea1f0f71c80301056e7a5ec0b125))
16
+ * **41-02:** add analytics and dependency-graph routes; refactor TimelinePage with section tabs ([4998f40](https://github.com/SienkLogic/plan-build-run/commit/4998f4093097953ca1e1e880413be60e07da08b4))
17
+ * **41-02:** add analytics/graph CSS sections and Mermaid CDN to Layout ([95d8853](https://github.com/SienkLogic/plan-build-run/commit/95d88536e406894021e454bb874d9dcc17abd179))
18
+ * **41-02:** add AnalyticsPanel and DependencyGraph components ([5bb080b](https://github.com/SienkLogic/plan-build-run/commit/5bb080bb363a3e2b56f5e2f2054c15e80dc6be23))
19
+ * **42-01:** create SettingsPage shell and ConfigEditor component (form + raw JSON modes) ([98caf06](https://github.com/SienkLogic/plan-build-run/commit/98caf0651314b4185b9c0e851d1607889956cb7f))
20
+ * **quick-008:** inject PBR workflow directive into SessionStart and PreCompact hooks ([c07574e](https://github.com/SienkLogic/plan-build-run/commit/c07574e32365c18e8c9239b8e4398330ba441182))
21
+
8
22
  ## [2.30.0](https://github.com/SienkLogic/plan-build-run/compare/plan-build-run-v2.29.0...plan-build-run-v2.30.0) (2026-02-24)
9
23
 
10
24
 
@@ -0,0 +1,240 @@
1
+ /**
2
+ * timeline.css — Timeline page styles
3
+ * Depends on tokens.css for design tokens (--space-*, --color-*, --radius-*, --font-mono)
4
+ */
5
+
6
+ .timeline {
7
+ display: flex;
8
+ flex-direction: column;
9
+ padding: var(--space-lg);
10
+ max-width: 1200px;
11
+ }
12
+
13
+ .timeline__filters {
14
+ display: flex;
15
+ flex-direction: row;
16
+ flex-wrap: wrap;
17
+ gap: var(--space-sm);
18
+ align-items: center;
19
+ padding: var(--space-md);
20
+ background: var(--color-surface);
21
+ border-radius: var(--radius-md);
22
+ margin-bottom: var(--space-lg);
23
+ border: 1px solid var(--color-border);
24
+ }
25
+
26
+ .timeline__filters label {
27
+ display: flex;
28
+ align-items: center;
29
+ gap: var(--space-xs);
30
+ cursor: pointer;
31
+ font-size: 0.875rem;
32
+ }
33
+
34
+ .timeline__stream {
35
+ position: relative;
36
+ }
37
+
38
+ .timeline__list {
39
+ list-style: none;
40
+ padding: 0;
41
+ margin: 0;
42
+ display: flex;
43
+ flex-direction: column;
44
+ gap: 0;
45
+ }
46
+
47
+ .timeline__event {
48
+ display: grid;
49
+ grid-template-columns: 1rem 10rem 9rem 1fr auto;
50
+ align-items: start;
51
+ gap: var(--space-sm);
52
+ padding: var(--space-sm) 0;
53
+ border-bottom: 1px solid var(--color-border);
54
+ }
55
+
56
+ .timeline__event-dot {
57
+ width: 0.5rem;
58
+ height: 0.5rem;
59
+ border-radius: 50%;
60
+ background: var(--color-accent);
61
+ margin-top: 0.35rem;
62
+ }
63
+
64
+ .timeline__event--commit .timeline__event-dot {
65
+ background: var(--color-info, #3b82f6);
66
+ }
67
+
68
+ .timeline__event--todo-completion .timeline__event-dot {
69
+ background: var(--color-success, #22c55e);
70
+ }
71
+
72
+ .timeline__event--phase-transition .timeline__event-dot {
73
+ background: var(--color-warning, #f59e0b);
74
+ }
75
+
76
+ .timeline__event-time {
77
+ font-family: var(--font-mono);
78
+ font-size: 0.8rem;
79
+ color: var(--color-text-dim);
80
+ }
81
+
82
+ .timeline__event-type {
83
+ font-size: 0.75rem;
84
+ padding: 0.1rem 0.4rem;
85
+ border-radius: var(--radius-sm);
86
+ background: var(--color-surface);
87
+ border: 1px solid var(--color-border);
88
+ color: var(--color-text-dim);
89
+ white-space: nowrap;
90
+ }
91
+
92
+ .timeline__event-title {
93
+ font-size: 0.9rem;
94
+ }
95
+
96
+ .timeline__event-author {
97
+ font-size: 0.8rem;
98
+ color: var(--color-text-dim);
99
+ }
100
+
101
+ .timeline__loading {
102
+ color: var(--color-text-dim);
103
+ padding: var(--space-md);
104
+ }
105
+
106
+ .timeline__empty {
107
+ color: var(--color-text-dim);
108
+ padding: var(--space-lg);
109
+ text-align: center;
110
+ }
111
+
112
+ .htmx-indicator {
113
+ opacity: 0;
114
+ transition: opacity 0.2s;
115
+ }
116
+
117
+ .htmx-request .htmx-indicator,
118
+ .htmx-request.htmx-indicator {
119
+ opacity: 1;
120
+ }
121
+
122
+ /* ---- Section tabs ---- */
123
+ .timeline__section-tabs {
124
+ display: flex;
125
+ gap: 0;
126
+ border-bottom: 1px solid var(--color-border);
127
+ margin-bottom: var(--space-lg);
128
+ }
129
+
130
+ .timeline__section-tab {
131
+ padding: var(--space-sm) var(--space-md);
132
+ border: none;
133
+ border-bottom: 2px solid transparent;
134
+ background: none;
135
+ cursor: pointer;
136
+ color: var(--color-text-dim);
137
+ font-size: 0.9rem;
138
+ font-family: var(--font-sans);
139
+ transition: all 0.15s;
140
+ }
141
+
142
+ .timeline__section-tab:hover { color: var(--color-text); }
143
+
144
+ .timeline__section-tab[aria-selected="true"] {
145
+ color: var(--color-accent);
146
+ border-bottom-color: var(--color-accent);
147
+ }
148
+
149
+ /* ---- Analytics panel ---- */
150
+ .analytics-panel {
151
+ display: flex;
152
+ flex-direction: column;
153
+ gap: var(--space-lg);
154
+ }
155
+
156
+ .analytics-panel__section {
157
+ background: var(--color-surface);
158
+ border: 1px solid var(--color-border);
159
+ border-radius: var(--radius-md);
160
+ padding: var(--space-md);
161
+ }
162
+
163
+ .analytics-panel__section h2 {
164
+ font-size: 1rem;
165
+ font-weight: 600;
166
+ margin: 0 0 var(--space-md);
167
+ }
168
+
169
+ .analytics-panel__stats {
170
+ display: flex;
171
+ flex-wrap: wrap;
172
+ gap: var(--space-md);
173
+ margin-bottom: var(--space-md);
174
+ }
175
+
176
+ .analytics-panel__stat {
177
+ display: flex;
178
+ flex-direction: column;
179
+ gap: var(--space-xs);
180
+ min-width: 8rem;
181
+ }
182
+
183
+ .analytics-panel__stat-value {
184
+ font-size: 1.5rem;
185
+ font-weight: 700;
186
+ font-family: var(--font-mono);
187
+ color: var(--color-accent);
188
+ }
189
+
190
+ .analytics-panel__stat-label {
191
+ font-size: 0.8rem;
192
+ color: var(--color-text-dim);
193
+ text-transform: uppercase;
194
+ letter-spacing: 0.05em;
195
+ }
196
+
197
+ .analytics-panel__table {
198
+ width: 100%;
199
+ border-collapse: collapse;
200
+ font-size: 0.875rem;
201
+ }
202
+
203
+ .analytics-panel__table th,
204
+ .analytics-panel__table td {
205
+ text-align: left;
206
+ padding: var(--space-xs) var(--space-sm);
207
+ border-bottom: 1px solid var(--color-border);
208
+ }
209
+
210
+ .analytics-panel__table th {
211
+ color: var(--color-text-dim);
212
+ font-weight: 600;
213
+ font-size: 0.8rem;
214
+ text-transform: uppercase;
215
+ letter-spacing: 0.04em;
216
+ }
217
+
218
+ .analytics-panel__empty,
219
+ .analytics-panel__loading {
220
+ color: var(--color-text-dim);
221
+ padding: var(--space-md);
222
+ }
223
+
224
+ /* ---- Dependency graph ---- */
225
+ .dep-graph {
226
+ background: var(--color-surface);
227
+ border: 1px solid var(--color-border);
228
+ border-radius: var(--radius-md);
229
+ padding: var(--space-md);
230
+ overflow-x: auto;
231
+ }
232
+
233
+ .dep-graph__container {
234
+ min-height: 12rem;
235
+ }
236
+
237
+ .dep-graph__loading {
238
+ color: var(--color-text-dim);
239
+ padding: var(--space-md);
240
+ }
@@ -36,6 +36,7 @@ export function Layout({ title, children, currentView }: LayoutProps) {
36
36
  <link rel="stylesheet" href="/css/status-colors.css" />
37
37
  <link rel="stylesheet" href="/css/command-center.css" />
38
38
  <link rel="stylesheet" href="/css/explorer.css" />
39
+ <link rel="stylesheet" href="/css/timeline.css" />
39
40
 
40
41
  {/* Prevent flash of wrong theme */}
41
42
  {html`<script>
@@ -47,6 +48,8 @@ export function Layout({ title, children, currentView }: LayoutProps) {
47
48
  }
48
49
  })();
49
50
  </script>`}
51
+ <script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js" defer></script>
52
+ {html`<script>document.addEventListener('DOMContentLoaded', function() { mermaid.initialize({ startOnLoad: false, theme: 'neutral' }); });</script>`}
50
53
  </head>
51
54
  <body>
52
55
  <a href="#main-content" class="skip-link">Skip to content</a>
@@ -0,0 +1,399 @@
1
+ interface ConfigEditorProps {
2
+ config: Record<string, unknown>;
3
+ }
4
+
5
+ function BoolField({ name, label, checked }: { name: string; label: string; checked: boolean }) {
6
+ return (
7
+ <label class="config-field config-field--check">
8
+ <input type="checkbox" name={name} checked={checked || false} />
9
+ {label}
10
+ </label>
11
+ );
12
+ }
13
+
14
+ function SelectField({
15
+ name,
16
+ label,
17
+ value,
18
+ options,
19
+ }: {
20
+ name: string;
21
+ label: string;
22
+ value: string;
23
+ options: string[];
24
+ }) {
25
+ return (
26
+ <label class="config-field">
27
+ <span class="config-field__label">{label}</span>
28
+ <select name={name}>
29
+ {options.map((o) => (
30
+ <option value={o} selected={o === value}>
31
+ {o}
32
+ </option>
33
+ ))}
34
+ </select>
35
+ </label>
36
+ );
37
+ }
38
+
39
+ function TextField({
40
+ name,
41
+ label,
42
+ value,
43
+ type,
44
+ readonly,
45
+ }: {
46
+ name: string;
47
+ label: string;
48
+ value: string | number;
49
+ type?: string;
50
+ readonly?: boolean;
51
+ }) {
52
+ return (
53
+ <label class="config-field">
54
+ <span class="config-field__label">{label}</span>
55
+ <input
56
+ type={type || 'text'}
57
+ name={name}
58
+ value={String(value ?? '')}
59
+ readonly={readonly}
60
+ />
61
+ </label>
62
+ );
63
+ }
64
+
65
+ function SectionTitle({ title }: { title: string }) {
66
+ return <h3 class="config-section__title">{title}</h3>;
67
+ }
68
+
69
+ export function ConfigEditor({ config }: ConfigEditorProps) {
70
+ const features = (config.features as Record<string, boolean>) || {};
71
+ const gates = (config.gates as Record<string, boolean>) || {};
72
+ const models = (config.models as Record<string, string>) || {};
73
+ const para = (config.parallelization as Record<string, unknown>) || {};
74
+ const planning = (config.planning as Record<string, unknown>) || {};
75
+ const git = (config.git as Record<string, string>) || {};
76
+ const safety = (config.safety as Record<string, boolean>) || {};
77
+ const localLlm = (config.local_llm as Record<string, unknown>) || {};
78
+ const llmFeatures = (localLlm.features as Record<string, boolean>) || {};
79
+ const llmMetrics = (localLlm.metrics as Record<string, unknown>) || {};
80
+ const llmAdvanced = (localLlm.advanced as Record<string, unknown>) || {};
81
+
82
+ const rawJson = JSON.stringify(config, null, 2);
83
+
84
+ return (
85
+ <div class="config-editor" x-data="{ mode: 'form' }">
86
+ {/* Mode toggle */}
87
+ <div class="config-mode-toggle" role="group" aria-label="Editor mode">
88
+ <button
89
+ type="button"
90
+ class="tab-btn"
91
+ x-on:click="mode = 'form'"
92
+ x-bind:class="{ active: mode === 'form' }"
93
+ >
94
+ Form
95
+ </button>
96
+ <button
97
+ type="button"
98
+ class="tab-btn"
99
+ x-on:click="mode = 'raw'"
100
+ x-bind:class="{ active: mode === 'raw' }"
101
+ >
102
+ Raw JSON
103
+ </button>
104
+ </div>
105
+
106
+ {/* Form mode */}
107
+ <div x-show="mode === 'form'">
108
+ <form
109
+ hx-post="/api/settings/config"
110
+ hx-target="#config-feedback"
111
+ hx-swap="innerHTML"
112
+ hx-encoding="application/x-www-form-urlencoded"
113
+ >
114
+ {/* General section */}
115
+ <div class="config-section">
116
+ <SectionTitle title="General" />
117
+ <SelectField
118
+ name="mode"
119
+ label="Mode"
120
+ value={String(config.mode ?? 'normal')}
121
+ options={['normal', 'autonomous', 'cautious']}
122
+ />
123
+ <SelectField
124
+ name="depth"
125
+ label="Depth"
126
+ value={String(config.depth ?? 'standard')}
127
+ options={['standard', 'comprehensive', 'light']}
128
+ />
129
+ <SelectField
130
+ name="context_strategy"
131
+ label="Context Strategy"
132
+ value={String(config.context_strategy ?? 'aggressive')}
133
+ options={['aggressive', 'balanced', 'conservative']}
134
+ />
135
+ <TextField
136
+ name="version"
137
+ label="Version"
138
+ value={String(config.version ?? '')}
139
+ readonly={true}
140
+ />
141
+ </div>
142
+
143
+ {/* Features section */}
144
+ <div class="config-section">
145
+ <SectionTitle title="Features" />
146
+ {Object.entries(features).map(([key, val]) => (
147
+ <BoolField name={`features.${key}`} label={key.replace(/_/g, ' ')} checked={val} />
148
+ ))}
149
+ </div>
150
+
151
+ {/* Gates section */}
152
+ <div class="config-section">
153
+ <SectionTitle title="Gates" />
154
+ {Object.entries(gates).map(([key, val]) => (
155
+ <BoolField name={`gates.${key}`} label={key.replace(/_/g, ' ')} checked={val} />
156
+ ))}
157
+ </div>
158
+
159
+ {/* Models section */}
160
+ <div class="config-section">
161
+ <SectionTitle title="Models" />
162
+ {Object.entries(models).map(([key, val]) => (
163
+ <SelectField
164
+ name={`models.${key}`}
165
+ label={key}
166
+ value={val}
167
+ options={['haiku', 'sonnet', 'opus', 'inherit']}
168
+ />
169
+ ))}
170
+ </div>
171
+
172
+ {/* Parallelization section */}
173
+ <div class="config-section">
174
+ <SectionTitle title="Parallelization" />
175
+ <BoolField name="parallelization.enabled" label="Enabled" checked={!!para.enabled} />
176
+ <BoolField name="parallelization.plan_level" label="Plan Level" checked={!!para.plan_level} />
177
+ <BoolField name="parallelization.task_level" label="Task Level" checked={!!para.task_level} />
178
+ <BoolField name="parallelization.use_teams" label="Use Teams" checked={!!para.use_teams} />
179
+ <TextField
180
+ name="parallelization.max_concurrent_agents"
181
+ label="Max Concurrent Agents"
182
+ value={Number(para.max_concurrent_agents ?? 3)}
183
+ type="number"
184
+ />
185
+ <TextField
186
+ name="parallelization.min_plans_for_parallel"
187
+ label="Min Plans for Parallel"
188
+ value={Number(para.min_plans_for_parallel ?? 2)}
189
+ type="number"
190
+ />
191
+ </div>
192
+
193
+ {/* Planning section */}
194
+ <div class="config-section">
195
+ <SectionTitle title="Planning" />
196
+ <BoolField
197
+ name="planning.commit_docs"
198
+ label="Commit Docs"
199
+ checked={!!planning.commit_docs}
200
+ />
201
+ <TextField
202
+ name="planning.max_tasks_per_plan"
203
+ label="Max Tasks per Plan"
204
+ value={Number(planning.max_tasks_per_plan ?? 8)}
205
+ type="number"
206
+ />
207
+ <BoolField
208
+ name="planning.search_gitignored"
209
+ label="Search Gitignored"
210
+ checked={!!planning.search_gitignored}
211
+ />
212
+ </div>
213
+
214
+ {/* Git section */}
215
+ <div class="config-section">
216
+ <SectionTitle title="Git" />
217
+ <SelectField
218
+ name="git.mode"
219
+ label="Mode"
220
+ value={git.mode ?? 'enabled'}
221
+ options={['enabled', 'disabled']}
222
+ />
223
+ <SelectField
224
+ name="git.branching"
225
+ label="Branching"
226
+ value={git.branching ?? 'none'}
227
+ options={['none', 'phase', 'milestone']}
228
+ />
229
+ <TextField
230
+ name="git.commit_format"
231
+ label="Commit Format"
232
+ value={git.commit_format ?? ''}
233
+ />
234
+ <TextField
235
+ name="git.phase_branch_template"
236
+ label="Phase Branch Template"
237
+ value={git.phase_branch_template ?? ''}
238
+ />
239
+ <TextField
240
+ name="git.milestone_branch_template"
241
+ label="Milestone Branch Template"
242
+ value={git.milestone_branch_template ?? ''}
243
+ />
244
+ </div>
245
+
246
+ {/* Safety section */}
247
+ <div class="config-section">
248
+ <SectionTitle title="Safety" />
249
+ <BoolField
250
+ name="safety.always_confirm_destructive"
251
+ label="Always Confirm Destructive"
252
+ checked={!!safety.always_confirm_destructive}
253
+ />
254
+ <BoolField
255
+ name="safety.always_confirm_external_services"
256
+ label="Always Confirm External Services"
257
+ checked={!!safety.always_confirm_external_services}
258
+ />
259
+ </div>
260
+
261
+ {/* Local LLM section */}
262
+ <div class="config-section">
263
+ <SectionTitle title="Local LLM" />
264
+ <BoolField name="local_llm.enabled" label="Enabled" checked={!!localLlm.enabled} />
265
+ <TextField name="local_llm.provider" label="Provider" value={String(localLlm.provider ?? '')} />
266
+ <TextField name="local_llm.endpoint" label="Endpoint" value={String(localLlm.endpoint ?? '')} />
267
+ <TextField name="local_llm.model" label="Model" value={String(localLlm.model ?? '')} />
268
+ <TextField
269
+ name="local_llm.timeout_ms"
270
+ label="Timeout (ms)"
271
+ value={Number(localLlm.timeout_ms ?? 30000)}
272
+ type="number"
273
+ />
274
+ <TextField
275
+ name="local_llm.max_retries"
276
+ label="Max Retries"
277
+ value={Number(localLlm.max_retries ?? 3)}
278
+ type="number"
279
+ />
280
+ <SelectField
281
+ name="local_llm.fallback"
282
+ label="Fallback"
283
+ value={String(localLlm.fallback ?? 'frontier')}
284
+ options={['frontier', 'skip', 'error']}
285
+ />
286
+ <SelectField
287
+ name="local_llm.routing_strategy"
288
+ label="Routing Strategy"
289
+ value={String(localLlm.routing_strategy ?? 'local_first')}
290
+ options={['local_first', 'frontier_first', 'always_local', 'always_frontier']}
291
+ />
292
+
293
+ {/* LLM Features sub-section */}
294
+ <div class="config-section config-section--nested">
295
+ <SectionTitle title="LLM Features" />
296
+ {Object.entries(llmFeatures).map(([key, val]) => (
297
+ <BoolField
298
+ name={`local_llm.features.${key}`}
299
+ label={key.replace(/_/g, ' ')}
300
+ checked={val}
301
+ />
302
+ ))}
303
+ {Object.keys(llmFeatures).length === 0 && (
304
+ <p class="config-empty">No local LLM features configured.</p>
305
+ )}
306
+ </div>
307
+
308
+ {/* LLM Metrics sub-section */}
309
+ <div class="config-section config-section--nested">
310
+ <SectionTitle title="LLM Metrics" />
311
+ <BoolField
312
+ name="local_llm.metrics.enabled"
313
+ label="Enabled"
314
+ checked={!!llmMetrics.enabled}
315
+ />
316
+ <BoolField
317
+ name="local_llm.metrics.show_session_summary"
318
+ label="Show Session Summary"
319
+ checked={!!llmMetrics.show_session_summary}
320
+ />
321
+ <TextField
322
+ name="local_llm.metrics.log_file"
323
+ label="Log File"
324
+ value={String(llmMetrics.log_file ?? '')}
325
+ />
326
+ <TextField
327
+ name="local_llm.metrics.frontier_token_rate"
328
+ label="Frontier Token Rate"
329
+ value={Number(llmMetrics.frontier_token_rate ?? 0)}
330
+ type="number"
331
+ />
332
+ </div>
333
+
334
+ {/* LLM Advanced sub-section */}
335
+ <div class="config-section config-section--nested">
336
+ <SectionTitle title="LLM Advanced" />
337
+ <TextField
338
+ name="local_llm.advanced.confidence_threshold"
339
+ label="Confidence Threshold"
340
+ value={Number(llmAdvanced.confidence_threshold ?? 0)}
341
+ type="number"
342
+ />
343
+ <TextField
344
+ name="local_llm.advanced.max_input_tokens"
345
+ label="Max Input Tokens"
346
+ value={Number(llmAdvanced.max_input_tokens ?? 0)}
347
+ type="number"
348
+ />
349
+ <TextField
350
+ name="local_llm.advanced.num_ctx"
351
+ label="Num Ctx"
352
+ value={Number(llmAdvanced.num_ctx ?? 0)}
353
+ type="number"
354
+ />
355
+ <TextField
356
+ name="local_llm.advanced.disable_after_failures"
357
+ label="Disable After Failures"
358
+ value={Number(llmAdvanced.disable_after_failures ?? 0)}
359
+ type="number"
360
+ />
361
+ <TextField
362
+ name="local_llm.advanced.keep_alive"
363
+ label="Keep Alive"
364
+ value={String(llmAdvanced.keep_alive ?? '')}
365
+ />
366
+ <BoolField
367
+ name="local_llm.advanced.shadow_mode"
368
+ label="Shadow Mode"
369
+ checked={!!llmAdvanced.shadow_mode}
370
+ />
371
+ </div>
372
+ </div>
373
+
374
+ <div class="config-actions">
375
+ <button type="submit" class="btn btn--primary">Save Config</button>
376
+ </div>
377
+ </form>
378
+ <div id="config-feedback" class="config-feedback" aria-live="polite"></div>
379
+ </div>
380
+
381
+ {/* Raw JSON mode */}
382
+ <div x-show="mode === 'raw'" x-cloak>
383
+ <form
384
+ hx-post="/api/settings/config"
385
+ hx-target="#config-feedback-raw"
386
+ hx-swap="innerHTML"
387
+ >
388
+ <textarea name="rawJson" rows={30} class="config-raw-json">
389
+ {rawJson}
390
+ </textarea>
391
+ <div class="config-actions">
392
+ <button type="submit" class="btn btn--primary">Save JSON</button>
393
+ </div>
394
+ </form>
395
+ <div id="config-feedback-raw" class="config-feedback" aria-live="polite"></div>
396
+ </div>
397
+ </div>
398
+ );
399
+ }