opencode-global-habit-profile 0.0.0-stage → 0.1.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.
@@ -0,0 +1,108 @@
1
+ function directoryName(value) {
2
+ const name = typeof value === 'string' ? value.trim() : ''
3
+ return name && name.length <= 128 && !/[\\/\u0000-\u001f\u007f]/.test(name) ? name : ''
4
+ }
5
+
6
+ export function groupCheckpoints(checkpoints) {
7
+ const groups = new Map()
8
+ const directories = new Map()
9
+ for (const item of checkpoints) {
10
+ const key = item.project
11
+ if (!groups.has(key)) groups.set(key, { key, project: item.project, items: [] })
12
+ groups.get(key).items.push(item)
13
+ const name = directoryName(item?.projectDirectoryName)
14
+ if (name) {
15
+ const createdAt = typeof item?.createdAt === 'string' ? item.createdAt : ''
16
+ if (!directories.has(key) || createdAt >= directories.get(key).createdAt) directories.set(key, { name, createdAt })
17
+ }
18
+ }
19
+ return [...groups.values()].map((group) => ({ ...group, directoryName: directories.get(group.key)?.name ?? '' }))
20
+ }
21
+
22
+ export function groupTodos(todos = [], includeArchived = false, directories = new Map()) {
23
+ const groups = new Map()
24
+ const names = new Map()
25
+ for (const todo of todos) {
26
+ if (!includeArchived && todo.status === 'archived') continue
27
+ if (!groups.has(todo.project)) groups.set(todo.project, { key: todo.project, project: todo.project, total: 0, pending: [], completed: [], archived: [] })
28
+ const group = groups.get(todo.project)
29
+ const name = directoryName(todo.projectDirectoryName)
30
+ if (name) {
31
+ const createdAt = typeof todo.createdAt === 'string' ? todo.createdAt : ''
32
+ if (!names.has(todo.project) || createdAt >= names.get(todo.project).createdAt) names.set(todo.project, { name, createdAt })
33
+ }
34
+ group[todo.status].push(todo)
35
+ group.total++
36
+ }
37
+ return [...groups.values()].map((group) => ({ ...group, directoryName: names.get(group.key)?.name ?? directories.get(group.key) ?? '' }))
38
+ }
39
+
40
+ export function projectDirectoryLabel(scope, language = 'es') {
41
+ return typeof scope?.directoryName === 'string' && scope.directoryName.trim()
42
+ ? scope.directoryName
43
+ : language === 'es' ? 'Directorio no disponible' : 'Directory unavailable'
44
+ }
45
+
46
+ export function checkpointTitle(item, language = 'es') {
47
+ return typeof item?.sessionTitle === 'string' && item.sessionTitle.trim()
48
+ ? item.sessionTitle
49
+ : language === 'es' ? 'Título no disponible' : 'Title unavailable'
50
+ }
51
+
52
+ function reportCategory(key) {
53
+ const match = /^(request:)?(tool|cli):([^:]+)$/.exec(key)
54
+ if (!match) return null
55
+ return {
56
+ group: match[2] === 'tool' ? 'tools' : 'commands',
57
+ kind: match[2],
58
+ name: match[3],
59
+ signal: match[1] ? 'request' : 'activity',
60
+ }
61
+ }
62
+
63
+ export function reportGroups(counts = {}) {
64
+ const groups = Object.fromEntries(['tools', 'commands'].map((key) => [key, { key, total: 0, items: [] }]))
65
+ for (const [key, count] of Object.entries(counts)) {
66
+ const category = reportCategory(key)
67
+ if (!category || !Number.isSafeInteger(count) || count < 1) continue
68
+ const group = groups[category.group]
69
+ group.total += count
70
+ group.items.push({ key, name: category.name, kind: category.kind, signal: category.signal, count })
71
+ }
72
+ for (const group of Object.values(groups)) group.items.sort((a, b) => b.count - a.count || a.key.localeCompare(b.key))
73
+ return Object.values(groups)
74
+ }
75
+
76
+ export function dailyTrend(daily = {}, today = new Date().toISOString().slice(0, 10)) {
77
+ const cutoff = new Date(`${today}T00:00:00.000Z`)
78
+ cutoff.setUTCDate(cutoff.getUTCDate() - 364)
79
+ const oldest = cutoff.toISOString().slice(0, 10)
80
+ return Object.entries(daily).filter(([date]) => /^\d{4}-\d{2}-\d{2}$/.test(date) && date >= oldest && date <= today && new Date(`${date}T00:00:00.000Z`).toISOString().slice(0, 10) === date).sort(([a], [b]) => a.localeCompare(b)).map(([date, counts]) => {
81
+ const row = { date, tools: 0, commands: 0 }
82
+ for (const [key, count] of Object.entries(counts ?? {})) {
83
+ const category = reportCategory(key)
84
+ if (category && Number.isSafeInteger(count) && count > 0) row[category.group] += count
85
+ }
86
+ return row
87
+ }).filter(({ tools, commands }) => tools + commands > 0)
88
+ }
89
+
90
+ export function trendBars(series) {
91
+ if (!series.length) return []
92
+ const width = 720, height = 180, left = 28, right = 16, top = 12, bottom = 28
93
+ const days = series.map(({ date }) => Date.parse(`${date}T00:00:00.000Z`))
94
+ const distinctDays = days.at(-1) !== days[0]
95
+ const span = distinctDays ? Math.round((days.at(-1) - days[0]) / 86_400_000) : 1
96
+ const max = Math.max(1, ...series.flatMap(({ tools, commands }) => [tools, commands]))
97
+ const plotWidth = width - left - right, plotHeight = height - top - bottom
98
+ const slot = plotWidth / (span + 1), barWidth = Math.max(.5, slot * .18)
99
+ const groupWidth = barWidth * 2
100
+ return series.flatMap((row, index) => {
101
+ const progress = distinctDays ? ((days[index] - days[0]) / 86_400_000) / span : .5
102
+ const center = left + groupWidth / 2 + progress * (plotWidth - groupWidth)
103
+ return ['tools', 'commands'].map((key, offset) => {
104
+ const barHeight = row[key] / max * plotHeight
105
+ return { date: row.date, key, count: row[key], x: center + (offset - 1) * barWidth, y: top + plotHeight - barHeight, width: barWidth, height: barHeight }
106
+ }).filter(({ count }) => count > 0)
107
+ })
108
+ }
@@ -0,0 +1,13 @@
1
+ export const viewIds = Object.freeze(['reports', 'checkpoints', 'todos'])
2
+
3
+ export function viewFromHash(hash) {
4
+ const view = hash.startsWith('#') ? hash.slice(1) : hash
5
+ return viewIds.includes(view) ? view : viewIds[0]
6
+ }
7
+
8
+ export function watchHashView(target, onChange) {
9
+ const sync = () => onChange(viewFromHash(target.location.hash))
10
+ target.addEventListener('hashchange', sync)
11
+ sync()
12
+ return () => target.removeEventListener('hashchange', sync)
13
+ }
@@ -0,0 +1,273 @@
1
+ import { randomBytes, timingSafeEqual } from "node:crypto"
2
+ import { lstatSync, mkdirSync, watch } from "node:fs"
3
+ import { mkdir, readFile, writeFile, lstat, unlink, rename, rmdir } from "node:fs/promises"
4
+ import { basename, dirname, join } from "node:path"
5
+ import { fileURLToPath } from "node:url"
6
+ import { homedir } from "node:os"
7
+ import { createServer, request as httpRequest } from "node:http"
8
+ import { clearCheckpoints, clearHabits, deleteCheckpoint, getData, storePath, updateCheckpoint } from "./store.js"
9
+
10
+ const maxBody = 8192
11
+ const distPath = join(fileURLToPath(new URL("../dist/", import.meta.url)))
12
+ const controlPath = "/__habit_memory_control"
13
+ export function createUiServer({ port = 0 } = {}) {
14
+ const token = randomBytes(32).toString("hex")
15
+ const clients = new Set()
16
+ let watcher, refreshTimer
17
+ const server = createServer(async (req, res) => {
18
+ const host = req.headers.host
19
+ if (![ `127.0.0.1:${server.address()?.port}`, `localhost:${server.address()?.port}` ].includes(host)) { res.writeHead(403).end("Forbidden"); return }
20
+ const method = req.method || "GET", url = new URL(req.url || "/", `http://${host}`)
21
+ if (url.pathname === controlPath) {
22
+ const supplied = req.headers["x-habit-token"]
23
+ const authorized = ["GET", "POST"].includes(method) && req.headers.origin === `http://${host}` && typeof supplied === "string" && Buffer.byteLength(supplied) === Buffer.byteLength(token) && timingSafeEqual(Buffer.from(supplied), Buffer.from(token))
24
+ if (!authorized) { res.writeHead(403).end(); return }
25
+ if (method === "GET") { res.writeHead(200, { "cache-control": "no-store" }).end("active"); return }
26
+ res.writeHead(200, { "cache-control": "no-store" }).end("stopping")
27
+ setImmediate(() => { server.closeStreams(); server.close() })
28
+ return
29
+ }
30
+ const securityHeaders = { "x-content-type-options": "nosniff", "content-security-policy": "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'", "referrer-policy": "no-referrer" }
31
+ const send = (status, payload, type = "application/json; charset=utf-8") => { res.writeHead(status, { "content-type": type, "cache-control": "no-store", ...securityHeaders }); res.end(payload) }
32
+ if (method === "GET" && url.pathname === "/") {
33
+ try { const html = (await readFile(join(distPath, "index.html"), "utf8")).replace("<head>", `<head><meta name="habit-token" content="${token}">`); send(200, html, "text/html; charset=utf-8") }
34
+ catch { send(503, "WebUI assets are missing (dist/index.html was not found). Run `npm run build` from the plugin directory to generate `dist/`.", "text/plain; charset=utf-8") }
35
+ return
36
+ }
37
+ const asset = /^\/assets\/(index-[a-zA-Z0-9_-]+\.(?:js|css))$/.exec(url.pathname)
38
+ if (method === "GET" && asset) {
39
+ try { const file = asset[1], type = file.endsWith(".js") ? "text/javascript; charset=utf-8" : "text/css; charset=utf-8"; send(200, await readFile(join(distPath, "assets", file)), type) }
40
+ catch { send(404, "Not found", "text/plain; charset=utf-8") }
41
+ return
42
+ }
43
+ if (method === "GET" && url.pathname === "/api/data") {
44
+ try { const data = await getData(); send(200, JSON.stringify({ habits: data.habits, checkpoints: data.checkpoints, todos: data.todos })) }
45
+ catch { send(500, JSON.stringify({ error: "Unable to read local memory" })) }
46
+ return
47
+ }
48
+ if (method === "GET" && url.pathname === "/api/events") {
49
+ res.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-store", connection: "keep-alive", ...securityHeaders })
50
+ res.write(": connected\n\n")
51
+ clients.add(res)
52
+ const heartbeat = setInterval(() => {
53
+ if (res.writableEnded) return
54
+ try { res.write(": ping\n\n") } catch { res.end() }
55
+ }, 30_000)
56
+ heartbeat.unref?.()
57
+ res.on("close", () => { clearInterval(heartbeat); clients.delete(res) })
58
+ return
59
+ }
60
+ if (method !== "POST" || !["/api/checkpoint/edit", "/api/checkpoint/delete", "/api/scope/delete", "/api/habits/clear"].includes(url.pathname)) { send(404, JSON.stringify({ error: "Not found" })); return }
61
+ const origin = req.headers.origin
62
+ if (origin !== `http://${host}` || req.headers["x-habit-token"] !== token) { send(403, JSON.stringify({ error: "Forbidden" })); return }
63
+ let raw = ""
64
+ try {
65
+ if (!/^application\/json(?:\s*;|$)/i.test(req.headers["content-type"] || "")) throw new Error()
66
+ for await (const chunk of req) { raw += chunk; if (Buffer.byteLength(raw) > maxBody) { send(413, JSON.stringify({ error: "Request too large" })); return } }
67
+ const body = JSON.parse(raw)
68
+ const clearingHabits = url.pathname === "/api/habits/clear", editing = url.pathname === "/api/checkpoint/edit", deleting = url.pathname === "/api/checkpoint/delete"
69
+ const fields = clearingHabits ? ["confirm"] : editing ? ["project", "context", "id", "summary"] : deleting ? ["project", "context", "id"] : ["project", "context"]
70
+ if (!body || Array.isArray(body) || Object.keys(body).length !== fields.length || Object.keys(body).some((key) => !fields.includes(key))) throw new Error()
71
+ if (clearingHabits) {
72
+ if (body.confirm !== true) throw new Error()
73
+ await clearHabits()
74
+ send(200, JSON.stringify({ changed: true }))
75
+ return
76
+ }
77
+ const changed = editing ? await updateCheckpoint(body.project, body.context, body.id, body.summary) : deleting ? await deleteCheckpoint(body.project, body.context, body.id) : await clearCheckpoints(body.project, body.context)
78
+ send(changed ? 200 : 404, JSON.stringify({ changed }))
79
+ } catch { send(400, JSON.stringify({ error: "Invalid request" })) }
80
+ })
81
+ server.on("clientError", (_error, socket) => socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"))
82
+ try {
83
+ const directory = dirname(storePath)
84
+ mkdirSync(directory, { recursive: true, mode: 0o700 })
85
+ watcher = watch(directory, (_event, filename) => {
86
+ if (filename && filename !== basename(storePath)) return
87
+ if (refreshTimer) return
88
+ refreshTimer = setTimeout(() => {
89
+ refreshTimer = undefined
90
+ for (const client of clients) {
91
+ if (client.writableEnded) { clients.delete(client); continue }
92
+ try { client.write("data: refresh\n\n") } catch { clients.delete(client) }
93
+ }
94
+ }, 100)
95
+ })
96
+ watcher.unref?.()
97
+ watcher.on("error", () => { watcher?.close(); watcher = undefined })
98
+ } catch {}
99
+ server.closeStreams = () => { for (const client of clients) { try { client.end() } catch {} } clients.clear() }
100
+ server.on("close", () => { clearTimeout(refreshTimer); watcher?.close(); server.closeStreams() })
101
+ server.habitToken = token
102
+ return server.listen(port, "127.0.0.1")
103
+ }
104
+
105
+ let uiServer
106
+
107
+ const ipcFiles = () => {
108
+ const base = process.env.XDG_RUNTIME_DIR && isSafeRuntimeDir(process.env.XDG_RUNTIME_DIR)
109
+ ? join(process.env.XDG_RUNTIME_DIR, "habit-memory")
110
+ : join(homedir(), ".local", "share", "habit-memory")
111
+ const dir = base
112
+ return { dir, descriptor: join(dir, "ui-control.json"), token: join(dir, "ui-control.token"), lock: join(dir, "ui-control.lock") }
113
+ }
114
+
115
+ function isSafeRuntimeDir(path) {
116
+ try {
117
+ const stat = lstatSync(path)
118
+ return stat.isDirectory() && stat.uid === process.getuid?.() && !(stat.mode & 0o077)
119
+ } catch { return false }
120
+ }
121
+
122
+ async function withLock(paths, fn) {
123
+ const deadline = Date.now() + 3000
124
+ while (true) {
125
+ try { await mkdir(paths.lock, { mode: 0o700 }); break }
126
+ catch (error) { if (error.code !== "EEXIST" || Date.now() >= deadline) throw error; await new Promise((resolve) => setTimeout(resolve, 25)) }
127
+ }
128
+ try { return await fn() } finally { await rmdir(paths.lock).catch(() => {}) }
129
+ }
130
+
131
+ async function readDescriptor(paths = ipcFiles()) {
132
+ const stat = await lstat(paths.descriptor)
133
+ if (!stat.isFile() || stat.uid !== process.getuid?.() || (stat.mode & 0o077)) throw new Error("Unsafe UI control descriptor")
134
+ const raw = await readFile(paths.descriptor, "utf8")
135
+ const descriptor = JSON.parse(raw)
136
+ const secret = await lstat(paths.token)
137
+ if (!secret.isFile() || secret.uid !== process.getuid?.() || (secret.mode & 0o077) || descriptor.token !== await readFile(paths.token, "utf8") || !/^[a-f0-9]{64}$/.test(descriptor.token) || !Number.isInteger(descriptor.port) || descriptor.port < 1 || descriptor.port > 65535) throw new Error("Invalid UI control descriptor")
138
+ return { ...descriptor, paths, identity: { descriptor: { dev: stat.dev, ino: stat.ino, raw }, token: { dev: secret.dev, ino: secret.ino } } }
139
+ }
140
+
141
+ function controlRequest(descriptor, { method = "GET", path = controlPath, timeout = 500, bodyLimit = 64 } = {}) {
142
+ return new Promise((resolve, reject) => {
143
+ const req = httpRequest({ hostname: "127.0.0.1", port: descriptor.port, path, method, headers: { host: `127.0.0.1:${descriptor.port}`, origin: `http://127.0.0.1:${descriptor.port}`, ...(path === controlPath ? { "x-habit-token": descriptor.token } : {}) } }, (res) => {
144
+ const chunks = []
145
+ let size = 0
146
+ res.on("data", (chunk) => {
147
+ size += chunk.length
148
+ if (size > bodyLimit) { req.destroy(new Error("Control response too large")); return }
149
+ chunks.push(chunk)
150
+ })
151
+ res.once("end", () => { clearTimeout(timer); resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() }) })
152
+ res.once("aborted", () => { clearTimeout(timer); reject(Object.assign(new Error("Control response aborted"), { code: "ECONNRESET" })) })
153
+ res.once("error", (error) => { clearTimeout(timer); reject(error) })
154
+ })
155
+ const timer = setTimeout(() => req.destroy(Object.assign(new Error("Control request timed out"), { code: "ETIMEDOUT" })), timeout)
156
+ req.once("error", (error) => { clearTimeout(timer); reject(error) })
157
+ req.end()
158
+ })
159
+ }
160
+
161
+ async function requestRemoteStop(descriptor) {
162
+ try {
163
+ const response = await controlRequest(descriptor, { method: "POST", timeout: 1000 })
164
+ if (response.status !== 200 || response.body !== "stopping") return false
165
+ const deadline = Date.now() + 1500
166
+ while (Date.now() < deadline) {
167
+ try { await controlRequest(descriptor, { path: "/", timeout: 100, bodyLimit: 0 }) }
168
+ catch (error) { if (error.code === "ECONNREFUSED") return true }
169
+ await new Promise((done) => setTimeout(done, 25))
170
+ }
171
+ return false
172
+ } catch { return false }
173
+ }
174
+
175
+ async function remoteIsActive(descriptor) {
176
+ for (let attempt = 0; attempt < 2; attempt++) {
177
+ try {
178
+ const response = await controlRequest(descriptor)
179
+ if (response.status === 200 && response.body === "active") return true
180
+ throw new Error("Existing UI control endpoint returned an unexpected response")
181
+ } catch (error) {
182
+ if (error.code === "ECONNREFUSED") return false
183
+ if (error.code === "ETIMEDOUT") throw new Error("Existing UI control endpoint timed out")
184
+ if (error.message.startsWith("Existing UI control endpoint")) throw error
185
+ if (attempt === 0 && error.code === "ECONNRESET") continue
186
+ const code = typeof error.code === "string" && /^[A-Z0-9_]{1,40}$/.test(error.code) ? ` (${error.code})` : ""
187
+ throw new Error(`Existing UI control endpoint could not be verified${code}`)
188
+ }
189
+ }
190
+ }
191
+
192
+ async function removeStale(paths, existing) {
193
+ const current = await readDescriptor(paths)
194
+ if (current.identity.descriptor.dev !== existing.identity.descriptor.dev || current.identity.descriptor.ino !== existing.identity.descriptor.ino || current.identity.descriptor.raw !== existing.identity.descriptor.raw || current.identity.token.dev !== existing.identity.token.dev || current.identity.token.ino !== existing.identity.token.ino || current.token !== existing.token) throw new Error("UI control state changed during recovery")
195
+ await unlink(paths.descriptor)
196
+ const tokenStat = await lstat(paths.token)
197
+ if (tokenStat.dev !== existing.identity.token.dev || tokenStat.ino !== existing.identity.token.ino || await readFile(paths.token, "utf8") !== existing.token) throw new Error("UI control token changed during recovery")
198
+ await unlink(paths.token)
199
+ }
200
+
201
+ async function removeOwned(paths, token, descriptorIdentity) {
202
+ try {
203
+ if (await readFile(paths.token, "utf8") !== token) return
204
+ if (!descriptorIdentity) { await unlink(paths.token); return }
205
+ const current = await readDescriptor(paths)
206
+ const stat = await lstat(paths.descriptor)
207
+ if (current.token !== token || stat.dev !== descriptorIdentity.dev || stat.ino !== descriptorIdentity.ino) return
208
+ await unlink(paths.descriptor)
209
+ if (await readFile(paths.token, "utf8") === token) await unlink(paths.token)
210
+ } catch {}
211
+ }
212
+
213
+ async function installControl(server) {
214
+ const paths = ipcFiles()
215
+ await mkdir(paths.dir, { recursive: true, mode: 0o700 })
216
+ const dirStat = await lstat(paths.dir)
217
+ if (!dirStat.isDirectory() || dirStat.uid !== process.getuid?.() || (dirStat.mode & 0o077)) throw new Error("Unsafe Habit Memory control directory")
218
+ await withLock(paths, async () => {
219
+ let descriptorExists = true
220
+ try { await lstat(paths.descriptor) }
221
+ catch (error) { if (error.code === "ENOENT") descriptorExists = false; else throw error }
222
+ if (descriptorExists) {
223
+ const existing = await readDescriptor(paths)
224
+ if (await remoteIsActive(existing)) {
225
+ server.remotePort = existing.port
226
+ server.close()
227
+ return
228
+ }
229
+ await removeStale(paths, existing)
230
+ }
231
+ const token = server.habitToken
232
+ let identity
233
+ server.once("close", () => {
234
+ if (identity) void withLock(paths, () => removeOwned(paths, token, identity)).catch(() => {})
235
+ })
236
+ const tempToken = `${paths.token}.${randomBytes(8).toString("hex")}.tmp`, tempDescriptor = `${paths.descriptor}.${randomBytes(8).toString("hex")}.tmp`
237
+ await writeFile(tempToken, token, { mode: 0o600, flag: "wx" })
238
+ await rename(tempToken, paths.token)
239
+ const publish = () => {
240
+ const descriptor = JSON.stringify({ port: server.address().port, token })
241
+ return writeFile(tempDescriptor, descriptor, { mode: 0o600, flag: "wx" }).then(() => rename(tempDescriptor, paths.descriptor))
242
+ }
243
+ if (!server.listening) throw new Error("UI listener closed before control publication")
244
+ await publish()
245
+ identity = await lstat(paths.descriptor)
246
+ })
247
+ }
248
+
249
+ export function startUiServer() {
250
+ if (uiServer?.listening) return uiServer
251
+ const server = createUiServer({ port: Number(process.env.HABIT_MEMORY_UI_PORT || 43109) })
252
+ uiServer = server
253
+ server.controlReady = new Promise((resolve, reject) => {
254
+ server.once("listening", () => installControl(server).then(resolve, (error) => {
255
+ server.close()
256
+ reject(error)
257
+ }))
258
+ server.once("error", reject)
259
+ })
260
+ server.once("close", () => { if (uiServer === server) uiServer = undefined })
261
+ server.on("listening", () => process.stdout.write(`Habit Memory UI: http://127.0.0.1:${server.address().port}\n`))
262
+ return server
263
+ }
264
+
265
+ export async function stopUiServer() {
266
+ if (!uiServer?.listening) return withLock(ipcFiles(), async () => { const descriptor = await readDescriptor(); if (!await requestRemoteStop(descriptor)) return false; return true }).catch(() => false)
267
+ const server = uiServer
268
+ await server.controlReady
269
+ server.closeStreams()
270
+ return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve(true)))
271
+ }
272
+
273
+ if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) startUiServer()
package/src/ui.css ADDED
@@ -0,0 +1,166 @@
1
+ :root {
2
+ color-scheme: dark;
3
+ --canvas: #0b1119;
4
+ --surface: #101923;
5
+ --surface-raised: #141f2b;
6
+ --ink: #dce6ed;
7
+ --muted: #91a2b1;
8
+ --brand: #59d6c8;
9
+ --danger: #ed8589;
10
+ --series-commands: #b69af7;
11
+ --line: color-mix(in srgb, var(--ink) 12%, transparent);
12
+ --line-soft: color-mix(in srgb, var(--ink) 7%, transparent);
13
+ --focus: var(--brand);
14
+ background: var(--canvas);
15
+ color: var(--ink);
16
+ font: 14px/1.5 system-ui, sans-serif;
17
+ }
18
+
19
+ :root[data-theme="light"] {
20
+ color-scheme: light;
21
+ --canvas: #f2f6f8;
22
+ --surface: #ffffff;
23
+ --surface-raised: #e8eff2;
24
+ --ink: #172733;
25
+ --muted: #536a78;
26
+ --brand: #087d75;
27
+ --danger: #a8323c;
28
+ --series-commands: #6045a8;
29
+ --line: color-mix(in srgb, var(--ink) 17%, transparent);
30
+ --line-soft: color-mix(in srgb, var(--ink) 10%, transparent);
31
+ }
32
+
33
+ * { box-sizing: border-box; }
34
+ html { scroll-behavior: smooth; }
35
+ body { margin: 0; background: var(--canvas); color: var(--ink); }
36
+ button, select { color: inherit; font: inherit; }
37
+
38
+ .app-shell { min-height: 100vh; background: var(--canvas); }
39
+ .sidebar {
40
+ display: flex;
41
+ flex-wrap: wrap;
42
+ align-items: center;
43
+ gap: .75rem 1rem;
44
+ padding: .875rem 1rem .75rem;
45
+ border-bottom: 1px solid var(--line);
46
+ background: var(--canvas);
47
+ }
48
+ .brand { color: var(--ink); font-size: 1rem; font-weight: 750; letter-spacing: -.035em; text-decoration: none; white-space: nowrap; }
49
+ .brand::before { content: "HM"; display: inline-grid; width: 1.75rem; height: 1.75rem; margin-right: .55rem; place-items: center; border: 1px solid color-mix(in srgb, var(--brand) 55%, transparent); border-radius: .35rem; color: var(--brand); font-size: .65rem; letter-spacing: .02em; vertical-align: -.2rem; }
50
+ .sidebar-nav { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); width: 100%; flex: none; gap: .25rem; padding: .2rem; border: 1px solid var(--line-soft); border-radius: .45rem; background: var(--surface); }
51
+ .sidebar-nav a { flex: 1; min-width: 0; padding: .45rem .55rem; border-radius: .3rem; color: var(--muted); font-size: .82rem; font-weight: 650; overflow-wrap: anywhere; text-align: center; text-decoration: none; }
52
+ .sidebar-nav a:hover, .sidebar-nav a:focus-visible { color: var(--ink); background: var(--surface-raised); }
53
+ .sidebar-nav a[aria-current="page"] { color: color-mix(in srgb, var(--brand) 90%, var(--ink)); background: var(--surface-raised); }
54
+ .local-note { flex: 1 1 9rem; max-width: 13rem; margin: 0; color: var(--muted); font-size: .75rem; }
55
+ .workspace { min-width: 0; }
56
+ .masthead { display: flex; flex-direction: column; align-items: flex-start; gap: 1rem; padding: 1.4rem 1rem 1.1rem; border-bottom: 1px solid var(--line-soft); }
57
+ .eyebrow { margin: 0 0 .35rem; color: var(--brand); font-size: .68rem; font-weight: 750; letter-spacing: .13em; }
58
+ h1 { margin: 0; font-size: clamp(1.55rem, 5vw, 2.1rem); font-weight: 650; letter-spacing: -.045em; line-height: 1.18; text-wrap: balance; }
59
+ .intro { margin: .3rem 0 0; color: var(--muted); font-size: .9rem; }
60
+ .masthead-controls { display: flex; align-items: center; gap: .5rem; }
61
+ .language { display: flex; align-items: center; gap: .45rem; color: var(--muted); font-size: .75rem; font-weight: 650; }
62
+ .language select, .theme-toggle { min-height: 2.25rem; padding: .35rem .6rem; border: 1px solid var(--line); border-radius: .35rem; background: var(--surface); color: var(--ink); font-size: .78rem; font-weight: 650; }
63
+ .theme-toggle { cursor: pointer; }
64
+
65
+ main { width: min(100%, 88rem); margin-inline: auto; padding: 1rem 1rem 3rem; }
66
+ .page-note, .section-note { color: var(--muted); }
67
+ .page-note { max-width: 60rem; margin: 0 0 1rem; font-size: .8rem; }
68
+ .stat-strip { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .6rem; margin-bottom: 1.8rem; }
69
+ .stat-card { display: flex; align-items: center; justify-content: space-between; gap: .5rem; min-width: 0; min-height: 3.8rem; padding: .65rem .8rem; border: 1px solid var(--line); border-radius: .4rem; background: var(--surface); color: var(--muted); font-size: .76rem; font-weight: 650; }
70
+ .stat-card span { min-width: 0; overflow-wrap: anywhere; }
71
+ .stat-card strong { color: var(--brand); font-size: 1.35rem; font-variant-numeric: tabular-nums; line-height: 1; }
72
+ .checkpoint-summary { grid-template-columns: minmax(0, 1fr); max-width: 20rem; margin: .8rem 0 1.2rem; }
73
+ main > section { margin-top: 2rem; scroll-margin-top: 1rem; }
74
+ h2 { display: flex; align-items: baseline; gap: .65rem; margin: 0; font-size: 1.15rem; font-weight: 680; letter-spacing: -.025em; text-wrap: balance; }
75
+ h3 { display: flex; align-items: flex-start; justify-content: space-between; gap: .5rem; margin: 0; font-size: .86rem; font-weight: 680; line-height: 1.4; text-wrap: balance; }
76
+ .section-note { margin: .25rem 0 .8rem; font-size: .8rem; }
77
+ .notice { display: flex; flex-wrap: wrap; align-items: center; gap: .65rem; color: var(--danger); }
78
+
79
+ .report-heading { display: flex; flex-direction: column; align-items: flex-start; gap: .5rem; }
80
+ .report-heading .section-note { max-width: 58rem; margin-bottom: 0; }
81
+ .include-archived { display: flex; align-items: center; gap: .5rem; min-height: 2.5rem; color: var(--muted); font-size: .8rem; font-weight: 650; cursor: pointer; }
82
+ .include-archived input { width: 1rem; height: 1rem; margin: 0; accent-color: var(--brand); }
83
+ .include-archived input:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }
84
+ .report-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: .6rem; margin-top: .8rem; }
85
+ .report-card, .trend-panel { min-width: 0; padding: .8rem .85rem; border: 1px solid var(--line); border-radius: .4rem; background: var(--surface); }
86
+ .report-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: .7rem; }
87
+ .report-total { flex: none; color: var(--brand); font-size: 1.1rem; font-variant-numeric: tabular-nums; }
88
+ .report-ranking { margin: .55rem 0 0; padding: 0; list-style: none; }
89
+ .report-ranking li { display: flex; justify-content: space-between; gap: .6rem; padding: .55rem 0; border-top: 1px solid var(--line-soft); }
90
+ .report-ranking li > strong { flex: none; color: var(--brand); font-variant-numeric: tabular-nums; }
91
+ .report-label { min-width: 0; overflow-wrap: anywhere; }
92
+ .report-label code { display: block; margin-top: .15rem; color: var(--muted); font-size: .7rem; }
93
+ .empty-report { margin: .7rem 0 0; color: var(--muted); font-size: .8rem; }
94
+ .trend-panel { margin-top: .65rem; }
95
+ .trend-legend { display: flex; flex-wrap: wrap; gap: .35rem 1rem; margin: .65rem 0 .2rem; padding: 0; color: var(--muted); font-size: .72rem; list-style: none; }
96
+ .trend-legend li::before { display: inline-block; width: .48rem; height: .48rem; margin-right: .35rem; border-radius: .1rem; background: currentColor; content: ""; }
97
+ .tools-series { color: var(--brand); }
98
+ .commands-series { color: var(--series-commands); }
99
+ .trend-chart { display: block; width: 100%; max-width: 100%; height: auto; overflow: hidden; }
100
+ .trend-chart .chart-axis { stroke: var(--line); stroke-width: 1; }
101
+ .trend-chart text { fill: var(--muted); font: 10px system-ui, sans-serif; }
102
+ .trend-chart rect { rx: 1px; }
103
+ .trend-chart .tools-series { fill: var(--brand); }
104
+ .trend-chart .commands-series { fill: var(--series-commands); }
105
+
106
+ .scope { min-width: 0; border: 1px solid var(--line); border-radius: .4rem; background: var(--surface); }
107
+ .scope-list { display: grid; grid-template-columns: minmax(0, 1fr); gap: .65rem; margin-top: .8rem; }
108
+ .project-summary { display: flex; align-items: center; gap: .75rem; min-width: 0; padding: .8rem; cursor: pointer; }
109
+ .project-summary::after { margin-left: auto; color: var(--muted); content: "⌄"; font-size: 1.2rem; }
110
+ .scope[open] > .project-summary::after { content: "⌃"; }
111
+ .project-summary:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
112
+ .scope-provenance { display: grid; min-width: 0; gap: .4rem; }
113
+ .scope-caption { display: flex; align-items: center; gap: .4rem; margin: 0; color: var(--brand); font-size: .67rem; font-weight: 750; letter-spacing: .09em; text-transform: uppercase; }
114
+ .scope-caption::before { content: ""; width: .35rem; height: .35rem; border-radius: 50%; background: var(--brand); }
115
+ .scope-caption .scope-directory { min-width: 0; color: var(--ink); font-size: .82rem; letter-spacing: normal; text-transform: none; overflow-wrap: anywhere; }
116
+ code { font-family: ui-monospace, "SFMono-Regular", Consolas, monospace; }
117
+ .project-summary code, .entry-context code { display: inline-block; max-width: 100%; padding: .15rem .35rem; border: 1px solid var(--line-soft); border-radius: .25rem; background: var(--canvas); font-size: .77rem; overflow-wrap: anywhere; }
118
+ .project-checkpoints { border-top: 1px solid var(--line-soft); }
119
+ .todo-status-list { display: grid; padding: .7rem .8rem; border-top: 1px solid var(--line-soft); }
120
+ .todo-status + .todo-status { padding-top: .65rem; border-top: 1px solid var(--line-soft); }
121
+ .todo-status-heading { color: var(--muted); font-size: .7rem; letter-spacing: .08em; text-transform: uppercase; }
122
+ .todo-status-heading span:last-child { color: var(--brand); font-variant-numeric: tabular-nums; }
123
+ .todo-items { margin: .35rem 0 0; padding: 0; list-style: none; }
124
+ .todo-items li { padding: .5rem 0; border-top: 1px solid var(--line-soft); overflow-wrap: anywhere; white-space: pre-wrap; }
125
+ .entry { display: flex; flex-direction: column; align-items: flex-start; gap: .65rem; padding: .8rem; }
126
+ .entry + .entry { border-top: 1px solid var(--line-soft); }
127
+ .entry-content { min-width: 0; }
128
+ .entry-title { display: block; margin: 0 0 .25rem; overflow-wrap: anywhere; }
129
+ .entry-summary { margin: 0 0 .3rem; white-space: pre-wrap; overflow-wrap: anywhere; }
130
+ .entry-context { display: flex; flex-wrap: wrap; align-items: center; gap: .3rem; margin: 0 0 .25rem; color: var(--muted); font-size: .72rem; }
131
+ time { color: var(--muted); font-size: .72rem; }
132
+ .entry-actions { display: flex; flex-wrap: wrap; gap: .4rem; }
133
+
134
+ button, select { min-height: 2.35rem; padding: .4rem .65rem; border: 1px solid var(--line); border-radius: .32rem; background: var(--surface-raised); color: var(--ink); cursor: pointer; }
135
+ button { font-size: .76rem; font-weight: 650; }
136
+ button:hover:not(:disabled), select:hover { border-color: color-mix(in srgb, var(--brand) 65%, var(--line)); }
137
+ .action-edit { border-color: color-mix(in srgb, var(--brand) 38%, var(--line)); color: var(--brand); }
138
+ .action-delete { border-color: color-mix(in srgb, var(--danger) 46%, var(--line)); color: var(--danger); }
139
+ .action-delete:hover:not(:disabled) { background: color-mix(in srgb, var(--danger) 10%, var(--surface)); }
140
+ button:focus-visible, select:focus-visible, a:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
141
+ button:disabled { opacity: .55; cursor: not-allowed; }
142
+
143
+ @media (min-width: 45rem) {
144
+ .app-shell { display: grid; grid-template-columns: 13.5rem minmax(0, 1fr); }
145
+ .sidebar { position: sticky; top: 0; display: flex; flex-direction: column; flex-wrap: nowrap; align-items: stretch; gap: 1.5rem; height: 100vh; min-height: 32rem; padding: 1.5rem 1rem 1rem; border-right: 1px solid var(--line); border-bottom: 0; }
146
+ .brand { font-size: 1.05rem; }
147
+ .sidebar-nav { display: flex; flex: none; flex-direction: column; gap: .25rem; padding: .25rem; }
148
+ .sidebar-nav a { flex: none; padding: .62rem .65rem; text-align: left; }
149
+ .local-note { flex: none; max-width: none; margin: auto 0 0; }
150
+ .masthead { flex-direction: row; align-items: center; justify-content: space-between; gap: 1rem; padding: 1.6rem 1.75rem 1.2rem; }
151
+ main { padding: 1.25rem 1.75rem 3rem; }
152
+ .report-heading { flex-direction: row; align-items: center; justify-content: space-between; }
153
+ .report-heading > button { flex: none; }
154
+ .entry { flex-direction: row; justify-content: space-between; }
155
+ .entry-actions { flex: none; }
156
+ }
157
+
158
+ @media (min-width: 68rem) {
159
+ .report-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
160
+ .scope-list { grid-template-columns: minmax(0, 1fr); }
161
+ }
162
+
163
+ @media (prefers-reduced-motion: reduce) {
164
+ html { scroll-behavior: auto; }
165
+ *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; }
166
+ }
package/src/ui.js ADDED
@@ -0,0 +1,5 @@
1
+ import { createApp } from 'vue'
2
+ import App from './App.vue'
3
+ import './ui.css'
4
+
5
+ createApp(App).mount('#app')