@hacksmith/doraval 0.2.42 → 0.2.44

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/README.md CHANGED
@@ -168,7 +168,7 @@ Exits with code `1` when errors are found. Pipe `--format json` output to `jq` o
168
168
 
169
169
  ## Providers
170
170
 
171
- Claude Code validators built in. Cursor, Codex, and Windsurf coming next.
171
+ Claude Code, Cursor, Codex, and Copilot CLI validators and scaffolding built in. OpenCode support is experimental.
172
172
 
173
173
  ## Links
174
174
 
package/bin/doraval.js CHANGED
@@ -599,7 +599,7 @@ var init_dist = __esm(() => {
599
599
  var require_package = __commonJS((exports, module) => {
600
600
  module.exports = {
601
601
  name: "@hacksmith/doraval",
602
- version: "0.2.42",
602
+ version: "0.2.44",
603
603
  author: "Saif",
604
604
  repository: {
605
605
  type: "git",
@@ -1719,19 +1719,17 @@ var init_list = __esm(() => {
1719
1719
  if (!args.all) {
1720
1720
  allEntries = allEntries.filter((e) => e.status === "active");
1721
1721
  }
1722
- const staged = [];
1722
+ let staged = [];
1723
1723
  try {
1724
1724
  const pdir = getPendingProjectDir(project);
1725
1725
  if (existsSync4(pdir)) {
1726
1726
  const files = readdirSync(pdir).filter((f) => f.endsWith(".md") && f !== ".gitkeep");
1727
- for (const f of files) {
1727
+ const stagedResults = await Promise.all(files.map(async (f) => {
1728
1728
  const txt = await Bun.file(join3(pdir, f)).text();
1729
1729
  const parsed = parseJournalEntries(txt);
1730
- for (const e of parsed) {
1731
- e._staged = true;
1732
- staged.push(e);
1733
- }
1734
- }
1730
+ return parsed.map((e) => ({ ...e, _staged: true }));
1731
+ }));
1732
+ staged = stagedResults.flat();
1735
1733
  }
1736
1734
  } catch {}
1737
1735
  if (args.format === "json") {
@@ -4407,6 +4405,7 @@ async function loadAllEntries(project) {
4407
4405
  parsed.forEach((e) => {
4408
4406
  e._staged = true;
4409
4407
  e._source = "staged";
4408
+ e._filename = f;
4410
4409
  staged.push(e);
4411
4410
  });
4412
4411
  }
@@ -4563,6 +4562,25 @@ var init_ui = __esm(() => {
4563
4562
  const { committed, staged } = await loadAllEntries(project || null);
4564
4563
  return Response.json({ ok: true, committed, staged });
4565
4564
  }
4565
+ if (url2.pathname === "/api/delete-staged" && req.method === "POST") {
4566
+ if (!project) {
4567
+ return Response.json({ error: "No project" }, { status: 400 });
4568
+ }
4569
+ const body = await req.json().catch(() => ({}));
4570
+ const filename = body.filename;
4571
+ if (!filename) {
4572
+ return Response.json({ error: "filename required" }, { status: 400 });
4573
+ }
4574
+ const pdir = getPendingProjectDir(project);
4575
+ const filePath = join18(pdir, filename);
4576
+ if (existsSync19(filePath)) {
4577
+ try {
4578
+ await Bun.file(filePath).unlink();
4579
+ } catch {}
4580
+ return Response.json({ ok: true });
4581
+ }
4582
+ return Response.json({ error: "not found" }, { status: 404 });
4583
+ }
4566
4584
  if (url2.pathname.startsWith("/api/")) {
4567
4585
  return Response.json({ error: "Not found" }, { status: 404 });
4568
4586
  }
@@ -4571,7 +4589,7 @@ var init_ui = __esm(() => {
4571
4589
  });
4572
4590
  const url = `http://${host === "0.0.0.0" ? "localhost" : host}:${server.port}`;
4573
4591
  const msg = `
4574
- ${import_picocolors16.default.blue("\u25C9")} doraval local dashboard
4592
+ ${import_picocolors16.default.blue("\u25C9")} dora local dashboard
4575
4593
  ${import_picocolors16.default.dim("Project:")} ${project ? import_picocolors16.default.white(project) : import_picocolors16.default.yellow("none (run dora init)")}
4576
4594
  ${import_picocolors16.default.dim("URL:")} ${import_picocolors16.default.underline(import_picocolors16.default.cyan(url))}
4577
4595
 
@@ -7726,6 +7744,7 @@ var init_completion = __esm(() => {
7726
7744
  "providers",
7727
7745
  "skill",
7728
7746
  "journal",
7747
+ "ui",
7729
7748
  "claude",
7730
7749
  "codex",
7731
7750
  "cursor",
@@ -7781,7 +7800,7 @@ complete -F _doraval_completions doraval
7781
7800
 
7782
7801
  _doraval() {
7783
7802
  local -a commands sub
7784
- commands=(validate init bump update providers skill journal claude codex cursor copilot)
7803
+ commands=(validate init bump update providers skill journal ui claude codex cursor copilot)
7785
7804
  _arguments -C \\
7786
7805
  '1: :->cmd' \\
7787
7806
  '*::arg:->args'
@@ -7814,7 +7833,7 @@ _doraval "$@"
7814
7833
  } else if (shell === "fish") {
7815
7834
  console.log(`# doraval fish completion
7816
7835
  complete -c doraval -f
7817
- complete -c doraval -n '__fish_use_subcommand' -a 'validate init bump update providers skill journal claude codex cursor copilot'
7836
+ complete -c doraval -n '__fish_use_subcommand' -a 'validate init bump update providers skill journal ui claude codex cursor copilot'
7818
7837
 
7819
7838
  complete -c doraval -n '__fish_seen_subcommand_from skill' -a 'validate drift judge'
7820
7839
  complete -c doraval -n '__fish_seen_subcommand_from journal' -a 'init list context hook update add sync'
package/bin/ui/index.html CHANGED
@@ -5,120 +5,338 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1"/>
6
6
  <title>dora • local dashboard</title>
7
7
  <script src="https://cdn.tailwindcss.com"></script>
8
+ <script>
9
+ tailwind.config = {
10
+ theme: {
11
+ extend: {
12
+ fontFamily: {
13
+ sans: ['system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif']
14
+ }
15
+ }
16
+ }
17
+ }
18
+ </script>
8
19
  <style>
9
20
  :root { color-scheme: dark; }
10
- body { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
11
- .section { @apply bg-zinc-900 border border-zinc-800 rounded-2xl p-5; }
12
- .entry { @apply border-l-4 pl-3 py-1.5; }
21
+
22
+ body {
23
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
24
+ }
25
+
26
+ .mono, pre, code, .font-mono {
27
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
28
+ }
29
+
30
+ .section {
31
+ @apply bg-zinc-900/70 border border-zinc-800/60 rounded-3xl p-6 backdrop-blur-sm;
32
+ transition: border-color 150ms ease, box-shadow 150ms ease;
33
+ }
34
+ .section:hover {
35
+ border-color: #27272a;
36
+ }
37
+
38
+ .entry {
39
+ @apply border-l-[3px] pl-4 py-2.5 transition-all duration-150;
40
+ }
41
+ .entry:hover {
42
+ transform: translateX(1px);
43
+ }
44
+
13
45
  .pb-strong { border-color: #f87171; }
14
46
  .pb-friction { border-color: #facc15; }
15
47
  .pb-nudge { border-color: #4ade80; }
16
- .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
48
+
17
49
  .push { font-variant-numeric: tabular-nums; }
18
- pre.context { white-space: pre-wrap; font-size: 13px; line-height: 1.45; }
19
- .staged { background: #27251f; }
50
+
51
+ pre.context {
52
+ white-space: pre-wrap;
53
+ font-size: 13px;
54
+ line-height: 1.5;
55
+ }
56
+
57
+ .staged {
58
+ background: #27251f;
59
+ border-color: #854d0e !important;
60
+ }
61
+
62
+ /* Emil-inspired polish */
63
+ input, textarea, button {
64
+ transition: all 150ms cubic-bezier(0.23, 1, 0.32, 1);
65
+ }
66
+
67
+ input:focus, textarea:focus {
68
+ outline: none;
69
+ border-color: #52525b;
70
+ box-shadow: 0 0 0 3px rgba(63, 66, 71, 0.2);
71
+ }
72
+
73
+ .btn-primary {
74
+ transition: transform 120ms cubic-bezier(0.23, 1, 0.32, 1),
75
+ background-color 150ms ease;
76
+ }
77
+ .btn-primary:active {
78
+ transform: scale(0.975);
79
+ }
80
+
81
+ .card-hover {
82
+ transition: transform 180ms cubic-bezier(0.23, 1, 0.32, 1),
83
+ box-shadow 180ms ease,
84
+ border-color 150ms ease;
85
+ }
86
+ .card-hover:hover {
87
+ transform: translateY(-1px);
88
+ box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
89
+ }
90
+
91
+ .modal {
92
+ animation: modalEnter 180ms cubic-bezier(0.23, 1, 0.32, 1) backwards;
93
+ }
94
+
95
+ @keyframes modalEnter {
96
+ from {
97
+ opacity: 0;
98
+ transform: scale(0.96) translateY(6px);
99
+ }
100
+ to {
101
+ opacity: 1;
102
+ transform: scale(1) translateY(0);
103
+ }
104
+ }
105
+
106
+ .toast {
107
+ animation: toastEnter 160ms cubic-bezier(0.23, 1, 0.32, 1);
108
+ }
109
+
110
+ .subtle-scale {
111
+ transition: transform 100ms ease;
112
+ }
113
+ .subtle-scale:active {
114
+ transform: scale(0.97);
115
+ }
116
+
117
+ /* Playful but restrained accent */
118
+ .accent {
119
+ color: #60a5fa;
120
+ }
20
121
  </style>
21
122
  </head>
22
123
  <body class="bg-zinc-950 text-zinc-200">
23
- <div class="max-w-6xl mx-auto p-6">
24
- <div class="flex items-center justify-between mb-6">
25
- <div class="flex items-center gap-3">
26
- <div class="text-2xl">🐱</div>
124
+ <div class="max-w-5xl mx-auto p-8">
125
+ <!-- Header -->
126
+ <div class="flex items-center justify-between mb-8">
127
+ <div class="flex items-center gap-4">
128
+ <div class="flex items-center justify-center w-9 h-9 rounded-2xl bg-zinc-900 border border-zinc-800 text-xl">
129
+ 🌀
130
+ </div>
27
131
  <div>
28
- <div class="font-semibold tracking-tight text-xl">doraval</div>
29
- <div class="text-xs text-zinc-500 -mt-0.5">local dashboard</div>
132
+ <div class="font-semibold tracking-tighter text-2xl">dora</div>
133
+ <div class="text-[10px] text-zinc-500 -mt-1 tracking-[1px] font-mono">LOCAL DASHBOARD</div>
30
134
  </div>
31
135
  </div>
32
- <div class="flex items-center gap-3 text-sm">
33
- <div id="project-badge" class="px-3 py-1 rounded-full bg-zinc-900 border border-zinc-800 text-xs"></div>
34
- <button onclick="refreshAll()" class="px-3 py-1 rounded-lg bg-zinc-900 hover:bg-zinc-800 border border-zinc-800 text-xs">Refresh</button>
35
- <button onclick="window.open('https://github.com/saif-shines/doraval','_blank')" class="px-3 py-1 rounded-lg bg-zinc-900 hover:bg-zinc-800 border border-zinc-800 text-xs">GitHub</button>
136
+
137
+ <div class="flex items-center gap-2 text-sm">
138
+ <div id="project-badge" class="px-3.5 py-1 rounded-full bg-zinc-900 border border-zinc-800 text-xs font-medium"></div>
139
+
140
+ <button onclick="refreshAll()"
141
+ class="px-4 py-1.5 rounded-2xl bg-zinc-900 hover:bg-zinc-800 border border-zinc-800 text-xs font-medium active:scale-[0.97] transition flex items-center gap-1.5">
142
+ <span>Refresh</span>
143
+ </button>
144
+
145
+ <button onclick="window.open('https://github.com/saif-shines/doraval','_blank')"
146
+ class="px-4 py-1.5 rounded-2xl bg-zinc-900 hover:bg-zinc-800 border border-zinc-800 text-xs font-medium active:scale-[0.97] transition">
147
+ GitHub
148
+ </button>
36
149
  </div>
37
150
  </div>
38
151
 
39
- <div class="grid grid-cols-1 lg:grid-cols-5 gap-4">
152
+ <div class="grid grid-cols-1 lg:grid-cols-5 gap-5">
40
153
  <!-- Capture -->
41
154
  <div class="lg:col-span-2 section">
42
- <div class="uppercase text-xs tracking-[1px] text-zinc-500 mb-2">Capture decision</div>
43
- <div class="space-y-3">
44
- <input id="title" class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2 text-sm placeholder:text-zinc-600 focus:outline-none focus:border-zinc-700" placeholder="Title (e.g. Prefer pure functions for new code)">
45
-
155
+ <div class="flex items-baseline justify-between mb-4">
156
+ <div>
157
+ <div class="text-sm font-semibold tracking-tight">Capture decision</div>
158
+ <div class="text-[10px] text-zinc-500 mt-0.5">Add to your journal</div>
159
+ </div>
160
+ <button onclick="clearForm()"
161
+ class="text-[10px] text-zinc-500 hover:text-zinc-300 px-2 py-0.5 rounded-lg hover:bg-zinc-950 transition">clear</button>
162
+ </div>
163
+
164
+ <div class="space-y-4">
165
+ <div>
166
+ <input id="title"
167
+ class="w-full bg-zinc-950 border border-zinc-800 focus:border-zinc-600 rounded-2xl px-4 py-3 text-[15px] placeholder:text-zinc-600"
168
+ placeholder="What did you decide?">
169
+ </div>
170
+
171
+ <!-- Playful pushback control -->
46
172
  <div>
47
- <div class="flex justify-between text-xs mb-1.5 text-zinc-400">
48
- <div>Pushback</div>
49
- <div id="pb-value" class="push font-medium">4</div>
173
+ <div class="flex items-center justify-between text-xs mb-2 px-0.5">
174
+ <div class="text-zinc-400">How strongly do you feel?</div>
175
+ <div id="pb-value" class="push font-semibold tabular-nums text-base">4</div>
50
176
  </div>
51
- <input id="pushback" type="range" min="1" max="10" step="1" value="4" class="w-full accent-zinc-400">
52
- <div class="flex justify-between text-[10px] text-zinc-500 mt-0.5">
53
- <div>Nudge</div><div>Friction</div><div>Strong</div>
177
+
178
+ <div class="relative">
179
+ <input id="pushback" type="range" min="1" max="10" step="1" value="4"
180
+ class="w-full accent-blue-400 cursor-pointer">
181
+ <div class="flex justify-between text-[10px] mt-1.5 px-0.5 text-zinc-500">
182
+ <div class="text-emerald-400/90">gentle nudge</div>
183
+ <div class="text-yellow-400/90">real friction</div>
184
+ <div class="text-red-400/90">non-negotiable</div>
185
+ </div>
54
186
  </div>
55
187
  </div>
56
188
 
57
- <input id="tags" class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2 text-sm placeholder:text-zinc-600 focus:outline-none focus:border-zinc-700" placeholder="tags (comma separated) e.g. architecture,cli">
189
+ <input id="tags"
190
+ class="w-full bg-zinc-950 border border-zinc-800 focus:border-zinc-600 rounded-2xl px-4 py-2.5 text-sm placeholder:text-zinc-600"
191
+ placeholder="tags: architecture, naming, cli">
58
192
 
59
- <textarea id="rationale" rows="3" class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-4 py-2 text-sm placeholder:text-zinc-600 focus:outline-none focus:border-zinc-700" placeholder="Why does this matter? (optional but recommended)"></textarea>
193
+ <div>
194
+ <textarea id="rationale" rows="2"
195
+ class="w-full bg-zinc-950 border border-zinc-800 focus:border-zinc-600 rounded-2xl px-4 py-3 text-sm placeholder:text-zinc-600 resize-y"
196
+ placeholder="Why does this matter? (helps future you and agents)"></textarea>
197
+ </div>
60
198
 
61
199
  <button onclick="addDecision()"
62
- class="w-full py-2.5 rounded-2xl bg-white text-black font-medium text-sm active:scale-[0.985] transition">Add to pending</button>
63
- <div id="add-status" class="text-xs text-emerald-400 h-4"></div>
200
+ class="w-full py-3 rounded-2xl bg-white hover:bg-zinc-100 active:scale-[0.975] text-black font-semibold text-[15px] shadow-sm flex items-center justify-center gap-2 transition">
201
+ Add to pending
202
+ <span class="text-xs font-normal opacity-50">⌘⏎</span>
203
+ </button>
204
+ <div id="add-status" class="text-xs h-4 text-emerald-400/90 px-1"></div>
64
205
  </div>
65
206
  </div>
66
207
 
67
208
  <!-- What agents see -->
68
209
  <div class="lg:col-span-3 section">
69
- <div class="flex items-center justify-between mb-2">
70
- <div>
71
- <span class="uppercase text-xs tracking-[1px] text-zinc-500">What agents will see on next session</span>
210
+ <div class="flex items-center justify-between mb-3">
211
+ <div class="flex items-center gap-2">
212
+ <div class="text-sm font-semibold tracking-tight">What agents will see</div>
213
+ <select id="agent-select" onchange="updateAgentPreview()" class="text-xs bg-zinc-950 border border-zinc-800 rounded-lg px-2 py-0.5 focus:outline-none">
214
+ <option value="claude">Claude (SessionStart)</option>
215
+ <option value="cursor">Cursor (workspaceOpen)</option>
216
+ <option value="codex">Codex (hooks)</option>
217
+ <option value="copilot">Copilot CLI</option>
218
+ <option value="opencode">OpenCode</option>
219
+ </select>
72
220
  </div>
73
- <button onclick="copyContext()" class="text-xs px-2.5 py-1 rounded-lg border border-zinc-800 hover:bg-zinc-900">Copy</button>
221
+ <button onclick="copyContext()"
222
+ class="text-xs px-3 py-1 rounded-xl border border-zinc-800 hover:bg-zinc-900 active:scale-[0.97] transition">Copy</button>
74
223
  </div>
75
- <pre id="context" class="context bg-black/60 border border-zinc-800 rounded-2xl p-4 text-zinc-300 overflow-auto max-h-[260px]"></pre>
76
- <div class="mt-2 text-[11px] text-zinc-500">This is the exact payload from <span class="font-mono">dora journal context</span>.</div>
224
+ <pre id="context" class="context bg-zinc-950 border border-zinc-800 rounded-2xl p-5 text-zinc-300 text-[13px] overflow-auto max-h-[220px] leading-relaxed"></pre>
225
+ <div id="agent-note" class="mt-2 text-[10px] text-zinc-500 px-1">Exact output from <span class="font-mono">dora journal context</span>. Injected via startup hook for the selected agent.</div>
77
226
  </div>
78
227
 
79
228
  <!-- Hooks -->
80
229
  <div class="lg:col-span-2 section">
81
- <div class="uppercase text-xs tracking-[1px] text-zinc-500 mb-3">Journal injection hooks</div>
230
+ <div class="text-sm font-semibold tracking-tight mb-3">Journal hooks</div>
82
231
  <div id="hooks" class="space-y-2 text-sm"></div>
83
- <div class="mt-3 text-[11px] text-zinc-500">Toggles call the same logic as <span class="font-mono">dora journal hook enable/disable</span>. Restart your agent after changes.</div>
232
+ <div class="mt-4 text-[10px] text-zinc-500 leading-snug">
233
+ Same as <span class="font-mono text-zinc-400">dora journal hook</span>. Restart Claude after toggling.
234
+ </div>
84
235
  </div>
85
236
 
86
237
  <!-- Journal -->
87
238
  <div class="lg:col-span-3 section">
88
- <div class="flex items-baseline justify-between mb-2">
89
- <div class="uppercase text-xs tracking-[1px] text-zinc-500">Journal entries</div>
90
- <div class="text-xs text-zinc-500" id="entry-count"></div>
239
+ <div class="flex items-center justify-between mb-4">
240
+ <div class="text-sm font-semibold tracking-tight">Journal entries</div>
241
+ <div class="flex items-center gap-2">
242
+ <input id="search" oninput="filterEntries()"
243
+ class="bg-zinc-950 border border-zinc-800 text-xs rounded-2xl px-3 py-1.5 placeholder:text-zinc-600 w-44 focus:outline-none focus:border-zinc-600"
244
+ placeholder="Search...">
245
+ <div class="text-[10px] text-zinc-500 tabular-nums" id="entry-count"></div>
246
+ </div>
91
247
  </div>
92
- <div id="entries" class="space-y-2 text-sm max-h-[380px] overflow-auto pr-1"></div>
248
+ <div id="entries" class="space-y-2 text-sm max-h-[340px] overflow-auto pr-2 -mr-1"></div>
93
249
  </div>
94
250
  </div>
95
251
 
96
- <div class="mt-6 flex flex-wrap gap-2 text-xs">
97
- <button onclick="doAction('sync')" class="px-3 py-1.5 rounded-2xl border border-zinc-800 hover:bg-zinc-900">Sync pending → remote</button>
98
- <button onclick="doAction('update')" class="px-3 py-1.5 rounded-2xl border border-zinc-800 hover:bg-zinc-900">Update local cache</button>
99
- <button onclick="doAction('open-dir')" class="px-3 py-1.5 rounded-2xl border border-zinc-800 hover:bg-zinc-900">Open ~/.doraval</button>
252
+ <!-- Footer actions -->
253
+ <div class="mt-8 flex items-center gap-2 text-sm">
254
+ <button onclick="doAction('sync')"
255
+ class="px-4 py-2 rounded-2xl border border-zinc-800 hover:bg-zinc-900 active:scale-[0.975] transition text-sm">
256
+ Sync to remote
257
+ </button>
258
+ <button onclick="doAction('update')"
259
+ class="px-4 py-2 rounded-2xl border border-zinc-800 hover:bg-zinc-900 active:scale-[0.975] transition text-sm">
260
+ Pull latest
261
+ </button>
262
+ <button onclick="doAction('open-dir')"
263
+ class="px-4 py-2 rounded-2xl border border-zinc-800 hover:bg-zinc-900 active:scale-[0.975] transition text-sm">
264
+ Open ~/.doraval
265
+ </button>
266
+
100
267
  <div class="flex-1"></div>
101
- <div class="text-zinc-500 self-center">Tip: use the CLI for heavy sync/agent enrichment. This UI is for speed.</div>
268
+ <div class="text-xs text-zinc-500">CLI is best for heavy lifting. This is for quick capture &amp; review.</div>
269
+ </div>
270
+ </div>
271
+
272
+ <!-- Modal -->
273
+ <div id="modal" class="hidden fixed inset-0 bg-black/80 backdrop-blur-sm items-center justify-center z-50 p-4" onclick="if (event.target.id === 'modal') closeModal()">
274
+ <div class="bg-zinc-900 border border-zinc-800 rounded-3xl w-full max-w-lg shadow-2xl modal" onclick="event.stopImmediatePropagation()">
275
+ <div class="p-6">
276
+ <div class="flex justify-between items-start">
277
+ <div id="modal-pb" class="inline-flex items-center px-3 py-0.5 rounded-full text-xs font-semibold tracking-wider"></div>
278
+ <button onclick="closeModal()" class="text-zinc-400 hover:text-white text-2xl leading-none -mr-1">×</button>
279
+ </div>
280
+
281
+ <h3 id="modal-title" class="text-xl font-semibold tracking-tight mt-3 pr-6"></h3>
282
+
283
+ <div class="flex items-center gap-2 mt-3 text-xs text-zinc-400">
284
+ <span id="modal-tags"></span>
285
+ <span class="text-zinc-700">•</span>
286
+ <span id="modal-meta"></span>
287
+ </div>
288
+
289
+ <div id="modal-rationale" class="mt-5 text-[14.5px] leading-relaxed text-zinc-300 whitespace-pre-wrap bg-zinc-950 border border-zinc-800 rounded-2xl p-5"></div>
290
+ </div>
291
+
292
+ <div class="border-t border-zinc-800 px-6 py-4 flex gap-2" id="modal-actions"></div>
102
293
  </div>
103
294
  </div>
104
295
 
296
+ <!-- Toasts -->
297
+ <div id="toasts" class="fixed bottom-5 right-5 flex flex-col gap-2 z-[70]"></div>
298
+
105
299
  <script>
106
300
  const $ = (id) => document.getElementById(id);
107
301
 
302
+ let allEntriesCache = [];
303
+ let currentProject = null;
304
+
305
+ function showToast(msg, type = 'info') {
306
+ const container = $('toasts');
307
+ const el = document.createElement('div');
308
+ el.className = `toast px-4 py-2.5 rounded-2xl text-sm border flex items-center gap-2 shadow-lg ${type === 'error'
309
+ ? 'bg-red-950/90 border-red-800 text-red-300'
310
+ : 'bg-zinc-900 border-zinc-800 text-zinc-200'}`;
311
+ el.innerHTML = `<span>${msg}</span>`;
312
+ container.appendChild(el);
313
+
314
+ // Fast dismiss, interruptible
315
+ setTimeout(() => {
316
+ if (el.parentNode) {
317
+ el.style.transition = 'opacity 160ms ease, transform 160ms ease';
318
+ el.style.opacity = '0';
319
+ el.style.transform = 'translateY(6px)';
320
+ setTimeout(() => el.remove(), 120);
321
+ }
322
+ }, 2200);
323
+ }
324
+
108
325
  async function fetchJSON(url, opts = {}) {
109
326
  const res = await fetch(url, opts);
110
- if (!res.ok) throw new Error(await res.text());
327
+ if (!res.ok) {
328
+ const txt = await res.text();
329
+ throw new Error(txt || res.statusText);
330
+ }
111
331
  return res.json();
112
332
  }
113
333
 
114
334
  function setProjectBadge(project) {
115
335
  const el = $('project-badge');
116
336
  el.textContent = project ? project : 'no project';
117
- el.className = 'px-3 py-1 rounded-full bg-zinc-900 border border-zinc-800 text-xs ' + (project ? '' : 'text-amber-400');
337
+ el.className = `px-3 py-1 rounded-full bg-zinc-900 border border-zinc-800 text-xs ${project ? '' : 'text-amber-400'}`;
118
338
  }
119
339
 
120
- let currentProject = null;
121
-
122
340
  async function loadStatus() {
123
341
  const s = await fetchJSON('/api/status');
124
342
  currentProject = s.project;
@@ -127,45 +345,97 @@ async function loadStatus() {
127
345
 
128
346
  async function loadContext() {
129
347
  const c = await fetchJSON('/api/context');
130
- $('context').textContent = c.text || '(no active high-pushback decisions yet)';
348
+ const pre = $('context');
349
+ pre.textContent = c.text || '(no active high-pushback decisions yet)';
350
+ updateAgentPreview();
351
+ }
352
+
353
+ function updateAgentPreview() {
354
+ const select = $('agent-select');
355
+ const note = $('agent-note');
356
+ if (!select || !note) return;
357
+ const agent = select.value;
358
+ const base = 'Exact output from <span class="font-mono">dora journal context</span>. ';
359
+ let extra = 'Injected via startup hook.';
360
+ if (agent === 'claude') extra = 'Injected on <b>SessionStart</b> for Claude Code.';
361
+ else if (agent === 'cursor') extra = 'Can be used with Cursor plugins (e.g. workspaceOpen or custom rules).';
362
+ else if (agent === 'codex') extra = 'Injected via Codex hooks for context.';
363
+ else if (agent === 'copilot') extra = 'Useful for Copilot CLI custom instructions / agents.';
364
+ else if (agent === 'opencode') extra = 'Pairs well with OpenCode rules, agents and skills.';
365
+ note.innerHTML = base + extra;
366
+ }
367
+
368
+ function renderEntry(e, container) {
369
+ const pb = e.pushback ?? 0;
370
+ let border = 'border-emerald-400';
371
+ let pbLabel = 'nudge';
372
+ if (pb >= 7) { border = 'border-red-400'; pbLabel = 'strong'; }
373
+ else if (pb >= 4) { border = 'border-yellow-400'; pbLabel = 'friction'; }
374
+
375
+ const div = document.createElement('div');
376
+ div.className = `entry ${border} bg-zinc-900/50 hover:bg-zinc-900/80 border-l-[3px] rounded-r-2xl p-3.5 cursor-pointer ${e._staged ? 'staged' : ''}`;
377
+ div.onclick = () => showModal(e);
378
+
379
+ const preview = (e.rationale || '').replace(/\s+/g, ' ').slice(0, 120);
380
+
381
+ div.innerHTML = `
382
+ <div class="flex items-start gap-3">
383
+ <div class="shrink-0 w-8 text-center pt-px">
384
+ <div class="text-[17px] font-semibold tabular-nums leading-none">${pb}</div>
385
+ <div class="text-[9px] uppercase tracking-[1px] text-zinc-500 mt-0.5">${pbLabel}</div>
386
+ </div>
387
+ <div class="min-w-0 flex-1 pt-0.5">
388
+ <div class="font-medium leading-tight pr-1">${e.title}${e._staged ? ' <span class="text-amber-400 text-[10px] align-baseline">staged</span>' : ''}</div>
389
+ <div class="text-xs text-zinc-400 mt-1.5 line-clamp-2">${preview || 'no rationale yet'}</div>
390
+ <div class="mt-2 flex items-center gap-x-2 gap-y-0.5 text-[10px] text-zinc-500">
391
+ <span>${(e.tags || []).join(' ') || '—'}</span>
392
+ <span class="text-zinc-700">·</span>
393
+ <span>${e.author || 'you'}</span>
394
+ </div>
395
+ </div>
396
+ </div>
397
+ `;
398
+ container.appendChild(div);
399
+ }
400
+
401
+ function filterEntries() {
402
+ const q = ($('search')?.value || '').toLowerCase().trim();
403
+ const container = $('entries');
404
+ container.innerHTML = '';
405
+
406
+ let filtered = allEntriesCache;
407
+ if (q) {
408
+ filtered = allEntriesCache.filter(e =>
409
+ (e.title || '').toLowerCase().includes(q) ||
410
+ (e.rationale || '').toLowerCase().includes(q) ||
411
+ (e.tags || []).some(t => t.toLowerCase().includes(q))
412
+ );
413
+ }
414
+
415
+ if (filtered.length === 0) {
416
+ container.innerHTML = `<div class="text-zinc-500 text-sm py-4">No matches.</div>`;
417
+ return;
418
+ }
419
+
420
+ filtered.forEach(e => renderEntry(e, container));
421
+ $('entry-count').textContent = `${filtered.length} / ${allEntriesCache.length} entries`;
131
422
  }
132
423
 
133
424
  async function loadEntries() {
134
425
  const data = await fetchJSON('/api/entries');
426
+ allEntriesCache = [...(data.staged || []), ...(data.committed || [])];
427
+
428
+ $('entry-count').textContent = allEntriesCache.length + ' entries';
135
429
  const container = $('entries');
136
430
  container.innerHTML = '';
137
- const all = [...(data.staged || []), ...(data.committed || [])];
138
-
139
- $('entry-count').textContent = all.length + ' entries';
140
431
 
141
- if (all.length === 0) {
142
- container.innerHTML = '<div class="text-zinc-500 text-sm py-4">No entries. Add your first decision on the left.</div>';
432
+ if (allEntriesCache.length === 0) {
433
+ container.innerHTML = '<div class="text-zinc-500 text-sm py-4">No entries yet. Capture one on the left.</div>';
143
434
  return;
144
435
  }
145
436
 
146
- all.forEach(e => {
147
- const pb = e.pushback ?? 0;
148
- let cls = 'pb-nudge';
149
- if (pb >= 7) cls = 'pb-strong';
150
- else if (pb >= 4) cls = 'pb-friction';
151
-
152
- const div = document.createElement('div');
153
- div.className = 'entry ' + cls + (e._staged ? ' staged rounded-r-xl' : '');
154
- div.innerHTML = `
155
- <div class="flex gap-2 items-start">
156
- <div class="push w-6 text-right font-semibold text-zinc-400">${pb}</div>
157
- <div class="flex-1 min-w-0">
158
- <div class="font-medium">${e.title}${e._staged ? ' <span class="text-amber-400 text-xs">(staged)</span>' : ''}</div>
159
- <div class="text-xs text-zinc-500 mt-0.5 line-clamp-2">${(e.rationale||'').replace(/\s+/g,' ').slice(0,160)}</div>
160
- <div class="mt-1 flex gap-2 text-[10px] text-zinc-500">
161
- <span>${(e.tags||[]).join(', ') || 'no tags'}</span>
162
- <span class="text-zinc-600">·</span>
163
- <span>${e.author || 'human'}</span>
164
- </div>
165
- </div>
166
- </div>`;
167
- container.appendChild(div);
168
- });
437
+ // show all initially
438
+ allEntriesCache.forEach(e => renderEntry(e, container));
169
439
  }
170
440
 
171
441
  async function loadHooks() {
@@ -173,88 +443,129 @@ async function loadHooks() {
173
443
  const el = $('hooks');
174
444
  el.innerHTML = '';
175
445
 
176
- const mk = (label, info, isGlobal) => {
446
+ const mkToggle = (label, info, isGlobal) => {
177
447
  const row = document.createElement('div');
178
- row.className = 'flex items-center justify-between gap-2 bg-zinc-950 border border-zinc-800 rounded-2xl px-4 py-2';
448
+ row.className = 'flex items-center justify-between gap-3 bg-zinc-950 border border-zinc-800 rounded-2xl px-4 py-2 text-sm';
449
+
450
+ const checked = info.enabled;
179
451
  row.innerHTML = `
180
- <div>
452
+ <div class="min-w-0">
181
453
  <div class="font-medium">${label}</div>
182
- <div class="text-[10px] text-zinc-500 font-mono truncate max-w-[260px]">${info.path}</div>
183
- </div>
184
- <div>
185
- <button data-action="${info.enabled ? 'disable' : 'enable'}" data-global="${isGlobal}"
186
- class="text-xs px-3 py-1 rounded-xl border ${info.enabled ? 'border-emerald-800 text-emerald-400 hover:bg-emerald-950' : 'border-zinc-700 hover:bg-zinc-900'}">
187
- ${info.enabled ? 'Disable' : 'Enable'}
188
- </button>
454
+ <div class="text-[10px] text-zinc-500 font-mono truncate">${info.path}</div>
189
455
  </div>
456
+ <label class="relative inline-flex items-center cursor-pointer">
457
+ <input type="checkbox" class="sr-only peer" ${checked ? 'checked' : ''}>
458
+ <div class="w-9 h-5 bg-zinc-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[1px] after:left-[1px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-emerald-600"></div>
459
+ </label>
190
460
  `;
191
- const btn = row.querySelector('button');
192
- btn.onclick = async () => {
193
- const action = btn.dataset.action;
194
- const g = btn.dataset.global === 'true';
461
+
462
+ const input = row.querySelector('input');
463
+ input.onchange = async () => {
464
+ const action = input.checked ? 'enable' : 'disable';
195
465
  try {
196
466
  await fetchJSON('/api/hooks/' + action, {
197
467
  method: 'POST',
198
468
  headers: {'content-type': 'application/json'},
199
- body: JSON.stringify({ global: g })
469
+ body: JSON.stringify({ global: isGlobal })
200
470
  });
471
+ showToast(`${action === 'enable' ? 'Enabled' : 'Disabled'} ${label}`, 'info');
201
472
  await loadHooks();
202
473
  } catch (e) {
203
- alert('Hook change failed: ' + e.message);
474
+ showToast('Hook change failed: ' + e.message, 'error');
475
+ input.checked = !input.checked; // revert
204
476
  }
205
477
  };
478
+
206
479
  el.appendChild(row);
207
480
  };
208
481
 
209
- mk('Claude (local)', h.local, false);
210
- mk('Claude (global ~/.claude)', h.global, true);
482
+ mkToggle('Claude (local)', h.local, false);
483
+ mkToggle('Claude (global)', h.global, true);
211
484
  }
212
485
 
213
486
  async function addDecision() {
214
487
  const title = $('title').value.trim();
215
- if (!title) { $('add-status').textContent = 'Title is required'; return; }
488
+ if (!title) {
489
+ showToast('Title is required', 'error');
490
+ return;
491
+ }
216
492
  const pushback = parseInt($('pushback').value, 10);
217
493
  const tags = $('tags').value.split(',').map(t => t.trim()).filter(Boolean);
218
494
  const rationale = $('rationale').value.trim();
219
495
 
220
- $('add-status').textContent = 'Saving...';
496
+ const btns = document.querySelectorAll('button');
497
+ // simple feedback on the primary button
498
+ const primary = Array.from(btns).find(b => b.textContent.includes('Add to pending'));
499
+ const originalText = primary ? primary.innerHTML : '';
500
+
221
501
  try {
222
502
  await fetchJSON('/api/add', {
223
503
  method: 'POST',
224
504
  headers: {'content-type': 'application/json'},
225
505
  body: JSON.stringify({ title, pushback, tags, rationale })
226
506
  });
227
- $('add-status').textContent = '✓ Added to pending';
228
- $('title').value = ''; $('tags').value = ''; $('rationale').value = '';
507
+
508
+ // Playful success moment
509
+ if (primary) {
510
+ primary.style.transitionDuration = '80ms';
511
+ primary.innerHTML = 'Captured ✓';
512
+ primary.style.background = '#166534';
513
+ primary.style.color = '#86efac';
514
+ }
515
+
516
+ showToast('Added to pending');
517
+
518
+ $('title').value = '';
519
+ $('tags').value = '';
520
+ $('rationale').value = '';
521
+
229
522
  await Promise.all([loadEntries(), loadContext()]);
230
- setTimeout(() => { $('add-status').textContent = ''; }, 1400);
523
+
524
+ // reset button
525
+ setTimeout(() => {
526
+ if (primary) {
527
+ primary.innerHTML = originalText;
528
+ primary.style.background = '';
529
+ primary.style.color = '';
530
+ primary.style.transitionDuration = '';
531
+ }
532
+ }, 900);
533
+
231
534
  } catch (e) {
232
- $('add-status').textContent = 'Error: ' + e.message;
535
+ showToast('Failed: ' + e.message, 'error');
536
+ if (primary) {
537
+ primary.innerHTML = originalText;
538
+ primary.style.background = '';
539
+ primary.style.color = '';
540
+ }
233
541
  }
234
542
  }
235
543
 
236
544
  async function copyContext() {
237
545
  const text = $('context').textContent;
546
+ if (!text || text.includes('no active')) {
547
+ showToast('Nothing to copy yet', 'error');
548
+ return;
549
+ }
238
550
  await navigator.clipboard.writeText(text);
239
- const t = document.createElement('span');
240
- t.textContent = ' copied!';
241
- t.className = 'text-emerald-400 ml-2 text-xs';
242
- $('context').after(t);
243
- setTimeout(() => t.remove(), 900);
551
+ const orig = event.currentTarget?.textContent;
552
+ showToast('Copied to clipboard');
244
553
  }
245
554
 
246
555
  async function refreshAll() {
247
556
  await Promise.all([loadEntries(), loadContext(), loadHooks()]);
557
+ showToast('Refreshed');
248
558
  }
249
559
 
250
560
  async function doAction(name) {
251
561
  if (name === 'open-dir') {
252
562
  const s = await fetchJSON('/api/status');
253
- alert('Open in Finder/Terminal: ' + s.doravalDir);
563
+ showToast('Path: ' + s.doravalDir);
564
+ // optionally try to open, but browser security limits it
254
565
  return;
255
566
  }
256
567
  if (name === 'sync' || name === 'update') {
257
- alert('Use the CLI for now: dora journal ' + name + ' (full git + gh flow)');
568
+ showToast('Use CLI: dora journal ' + name);
258
569
  return;
259
570
  }
260
571
  }
@@ -262,22 +573,159 @@ async function doAction(name) {
262
573
  function setupSlider() {
263
574
  const slider = $('pushback');
264
575
  const val = $('pb-value');
265
- const update = () => { val.textContent = slider.value; };
576
+
577
+ const update = () => {
578
+ const v = parseInt(slider.value, 10);
579
+ val.textContent = v;
580
+
581
+ val.className = `push font-semibold tabular-nums text-lg transition-colors ${v >= 7 ? 'text-red-400' : v >= 4 ? 'text-yellow-400' : 'text-emerald-400'}`;
582
+ };
583
+
266
584
  slider.addEventListener('input', update);
267
585
  update();
268
586
  }
269
587
 
588
+
589
+
590
+ function showModal(e) {
591
+ const modal = $('modal');
592
+ modal.classList.remove('hidden');
593
+ modal.classList.add('flex');
594
+
595
+ const pb = e.pushback ?? 0;
596
+ let pbColor = 'bg-emerald-400 text-black';
597
+ let label = 'NUDGE';
598
+ if (pb >= 7) { pbColor = 'bg-red-500 text-white'; label = 'STRONG'; }
599
+ else if (pb >= 4) { pbColor = 'bg-amber-400 text-black'; label = 'FRICTION'; }
600
+
601
+ $('modal-pb').className = `inline-flex items-center px-3 py-px rounded-full text-[10px] font-semibold tracking-[0.5px] ${pbColor}`;
602
+ $('modal-pb').textContent = `${pb} — ${label}`;
603
+
604
+ $('modal-title').textContent = e.title;
605
+
606
+ const tagsStr = (e.tags || []).length ? (e.tags || []).join(' · ') : 'no tags';
607
+ $('modal-tags').innerHTML = tagsStr;
608
+
609
+ $('modal-meta').innerHTML = `${e.author || 'human'} · ${e.date || ''} ${e._staged ? '<span class="text-amber-400">· staged</span>' : ''}`;
610
+
611
+ $('modal-rationale').textContent = e.rationale || '(no rationale)';
612
+
613
+ const actions = $('modal-actions');
614
+ actions.innerHTML = '';
615
+
616
+ if (e._staged && e._filename) {
617
+ const del = document.createElement('button');
618
+ del.className = 'px-4 py-1.5 text-sm rounded-2xl border border-red-900/60 text-red-400 hover:bg-red-950/50 active:scale-[0.975] transition';
619
+ del.textContent = 'Delete staged';
620
+ del.onclick = async () => {
621
+ if (!confirm('Remove this staged entry?')) return;
622
+ try {
623
+ await fetchJSON('/api/delete-staged', {
624
+ method: 'POST',
625
+ headers: { 'content-type': 'application/json' },
626
+ body: JSON.stringify({ filename: e._filename })
627
+ });
628
+ closeModal();
629
+ await Promise.all([loadEntries(), loadContext()]);
630
+ showToast('Removed from pending');
631
+ } catch (err) {
632
+ showToast('Delete failed', 'error');
633
+ }
634
+ };
635
+ actions.appendChild(del);
636
+ }
637
+
638
+ const closeBtn = document.createElement('button');
639
+ closeBtn.className = 'ml-auto px-5 py-1.5 text-sm rounded-2xl border border-zinc-700 hover:bg-zinc-800 active:scale-[0.975] transition';
640
+ closeBtn.textContent = 'Done';
641
+ closeBtn.onclick = closeModal;
642
+ actions.appendChild(closeBtn);
643
+ }
644
+
645
+ function closeModal() {
646
+ const m = $('modal');
647
+ m.classList.remove('flex');
648
+ m.classList.add('hidden');
649
+ }
650
+
651
+ function setupSlider() {
652
+ const slider = $('pushback');
653
+ const val = $('pb-value');
654
+ const update = () => {
655
+ const v = parseInt(slider.value, 10);
656
+ val.textContent = v;
657
+ val.className = `push font-semibold tabular-nums ${v >= 7 ? 'text-red-400' : v >= 4 ? 'text-yellow-400' : 'text-emerald-400'}`;
658
+ };
659
+ slider.addEventListener('input', update);
660
+ update();
661
+
662
+ // allow keyboard on slider
663
+ slider.addEventListener('keydown', (ev) => {
664
+ if (ev.key === 'Enter') {
665
+ ev.preventDefault();
666
+ addDecision();
667
+ }
668
+ });
669
+ }
670
+
671
+ function clearForm() {
672
+ $('title').value = '';
673
+ $('tags').value = '';
674
+ $('rationale').value = '';
675
+ $('pb-value').textContent = $('pushback').value = '4';
676
+ }
677
+
678
+ function setupCaptureKeys() {
679
+ const title = $('title');
680
+ const rationale = $('rationale');
681
+
682
+ [title, rationale].forEach(el => {
683
+ el.addEventListener('keydown', (e) => {
684
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
685
+ e.preventDefault();
686
+ addDecision();
687
+ }
688
+ });
689
+ });
690
+
691
+ // focus title on load
692
+ setTimeout(() => title.focus(), 300);
693
+ }
694
+
270
695
  async function boot() {
271
696
  setupSlider();
697
+ setupCaptureKeys();
698
+
272
699
  await loadStatus();
273
700
  await Promise.all([loadContext(), loadEntries(), loadHooks()]);
274
- // gentle polling so the UI feels alive when you add via CLI too
701
+
702
+ const search = $('search');
703
+ if (search) {
704
+ search.addEventListener('keydown', (e) => {
705
+ if (e.key === 'Escape') {
706
+ search.value = '';
707
+ filterEntries();
708
+ }
709
+ });
710
+ }
711
+
712
+ // Light polling (only when visible)
275
713
  setInterval(() => {
276
- loadEntries().catch(()=>{});
277
- loadContext().catch(()=>{});
278
- }, 8000);
714
+ if (!document.hidden) {
715
+ loadEntries().catch(() => {});
716
+ loadContext().catch(() => {});
717
+ }
718
+ }, 9000);
279
719
  }
280
720
 
721
+ window.addDecision = addDecision;
722
+ window.refreshAll = refreshAll;
723
+ window.copyContext = copyContext;
724
+ window.doAction = doAction;
725
+ window.closeModal = closeModal;
726
+ window.clearForm = clearForm;
727
+ window.updateAgentPreview = updateAgentPreview;
728
+
281
729
  boot().catch(console.error);
282
730
  </script>
283
731
  </body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hacksmith/doraval",
3
- "version": "0.2.42",
3
+ "version": "0.2.44",
4
4
  "author": "Saif",
5
5
  "repository": {
6
6
  "type": "git",