feat(heph-pwa): mobile app shell — views, quick-add, triage, search, voice

A buildless, installable PWA that mirrors heph-tui: sidebar of built-in views
(tom/tasks/work/chores/ondeck/inbox) + projects, a task list with attention
flags / project bullets / date chips, tap-to-expand triage (done/drop/skip/
attention/reschedule/move/delete + undo), full-text search, and a read-only
context+log preview. The primary surface is the quick-add modal (FAB or Cmd-'),
which live-parses the TUI syntax into preview chips and supports voice via
on-device dictation / the Web Speech API. rpc.js is the online-only JSON-RPC
client mirroring heph-tui's Backend; settings persist in localStorage. Service
worker caches the app shell for offline launch.

Verified end-to-end against a local server-mode hephd (--web-root): the app
boots, calls the view RPC, and renders RankedTasks in headless Chrome.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-04 16:59:37 -07:00
commit 4baa8e1c9d
13 changed files with 1709 additions and 0 deletions

63
heph-pwa/README.md Normal file
View file

@ -0,0 +1,63 @@
# heph-pwa
A phone-first, installable **Progressive Web App** that mirrors `heph-tui`:
browse the built-in views and projects, triage tasks, and — the primary use
case — capture tasks fast with the same quick-add syntax as the TUI's `a` /
Cmd-' popover. Context/KB is read-only here.
Full guide: [`docs/how-to/heph-pwa.md`](../docs/how-to/heph-pwa.md).
## What it is
- **Thin, online-only client.** Every read/write is a JSON-RPC call to a
server-mode `hephd` (the sync hub). No local replica, no offline write queue.
- **Buildless.** Plain ES modules, no bundler, no `npm install`. Serve the
directory and go.
- **Same parser as the TUI.** `src/quickadd.js` + `src/datespec.js` are faithful
ports of the Rust `hephd::quickadd` / `hephd::datespec` modules, verified by
parity tests against the original Rust unit cases.
## Layout
```
index.html # app shell
styles.css # dark, terminal-flavored, touch-tuned
manifest.webmanifest # PWA manifest (installable)
sw.js # service worker — caches the app shell for offline launch
icons/ # app icons (svg + rasterized png, incl. maskable)
src/
app.js # UI controller: views, list, quick-add, triage, search, voice
rpc.js # hephd JSON-RPC-over-HTTP client + settings (localStorage)
quickadd.js # quick-add parser (port of quickadd.rs)
datespec.js # date + recurrence parser (port of datespec.rs)
fmt.js # display helpers (date chips, attention colors, bullets)
test/
parsers.test.mjs # parity tests for the parser ports
```
## Run it
Serve from the hub (recommended — same-origin, no CORS):
```bash
hephd --mode server --http-addr 0.0.0.0:8787 --web-root /path/to/heph-pwa
# then open http://<host>:8787/ on your phone and Add to Home Screen
```
Or from any static server (the hub now sends CORS headers, so cross-origin
`/rpc` calls work); set the hub URL in the app's Settings screen.
## Test
```bash
node --test heph-pwa/test/parsers.test.mjs
```
## Status / next steps
First cut (C1). Known gaps, roughly in priority order:
- In-app OIDC device-code login (today: paste a bearer token in Settings).
- Offline write queue / CRDT replica (today: online-only).
- Read-only context could grow wiki-link navigation.
- A native Swift wrapper, if/when an Apple Developer account is in play.

BIN
heph-pwa/icons/icon-180.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
heph-pwa/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
heph-pwa/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

14
heph-pwa/icons/icon.svg Normal file
View file

@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="#15181d"/>
<!-- anvil: the forge of Hephaestus -->
<g fill="#6db3f2">
<!-- horn + body -->
<path d="M120 214 h300 a16 16 0 0 1 16 16 v8 a40 40 0 0 1 -40 40 h-70
l-14 40 h-92 l-14 -40 h-44 a48 48 0 0 1 -48 -48 v-8
a8 8 0 0 1 8 -8 z"/>
<!-- waist -->
<rect x="206" y="338" width="100" height="34" rx="6"/>
<!-- base -->
<rect x="150" y="372" width="212" height="40" rx="12"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 578 B

24
heph-pwa/index.html Normal file
View file

@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1"
/>
<meta name="theme-color" content="#15181d" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="heph" />
<title>heph</title>
<link rel="manifest" href="./manifest.webmanifest" />
<link rel="icon" href="./icons/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="./icons/icon-180.png" />
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div id="app"></div>
<noscript>heph needs JavaScript enabled.</noscript>
<script type="module" src="./src/app.js"></script>
</body>
</html>

View file

@ -0,0 +1,17 @@
{
"name": "heph",
"short_name": "heph",
"description": "Capture and triage hephaestus tasks from your phone.",
"start_url": "./",
"scope": "./",
"display": "standalone",
"orientation": "portrait",
"background_color": "#15181d",
"theme_color": "#15181d",
"icons": [
{ "src": "./icons/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
{ "src": "./icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "./icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "./icons/icon-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}

800
heph-pwa/src/app.js Normal file
View file

@ -0,0 +1,800 @@
// heph-pwa — a mobile-first browser mirror of heph-tui. Browse the built-in
// views and projects, triage tasks, and (the primary use case) capture new
// tasks fast with the same quick-add syntax as the TUI's `a` / Cmd-' popover.
//
// Online-only thin client: every action is an RPC to the configured hub (see
// rpc.js). Context/KB is read-only here (no nvim editing surface).
import { Client, loadSettings, saveSettings, RpcError } from "./rpc.js";
import { parse as quickParse } from "./quickadd.js";
import { today, parseDate, toEpochMs } from "./datespec.js";
import {
ATTENTION_COLORS,
fmtRelative,
hasFlag,
isOverdue,
nextAttention,
projectColor,
} from "./fmt.js";
// The built-in views, in the TUI sidebar order (filter.rs BUILTIN_VIEWS).
const VIEWS = [
{ id: "tom", title: "Top of Mind" },
{ id: "tasks", title: "Tasks" },
{ id: "work", title: "Work Tasks" },
{ id: "chores", title: "Chores" },
{ id: "ondeck", title: "On Deck" },
{ id: "inbox", title: "Inbox" },
];
const state = {
settings: loadSettings(),
client: null,
target: { type: "view", id: "tom", title: "Top of Mind" },
tasks: [],
projects: [],
expandedId: null,
loading: false,
error: null,
search: null, // null, or { query, results }
lastUndo: null, // { label, run }
};
state.client = new Client(state.settings);
// --- tiny DOM helper --------------------------------------------------------
/** h("div", {class:"x", onclick:fn}, child, child...) → HTMLElement. */
function h(tag, props = {}, ...children) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(props || {})) {
if (v == null || v === false) continue;
if (k === "class") el.className = v;
else if (k === "html") el.innerHTML = v;
else if (k.startsWith("on") && typeof v === "function") {
el.addEventListener(k.slice(2).toLowerCase(), v);
} else el.setAttribute(k, v === true ? "" : String(v));
}
for (const c of children.flat()) {
if (c == null || c === false) continue;
el.append(c.nodeType ? c : document.createTextNode(String(c)));
}
return el;
}
const $ = (sel) => document.querySelector(sel);
function toast(message, action) {
const root = $("#toast");
root.innerHTML = "";
const node = h(
"div",
{ class: "toast-body" },
h("span", {}, message),
action &&
h(
"button",
{
class: "toast-action",
onclick: () => {
root.innerHTML = "";
action.run();
},
},
action.label,
),
);
root.append(node);
if (!action) setTimeout(() => (root.innerHTML === "" ? null : (root.innerHTML = "")), 2600);
}
// --- data -------------------------------------------------------------------
async function reload() {
if (!state.client.configured) {
state.error = "Set your hub URL in Settings to begin.";
render();
openSettings();
return;
}
state.loading = true;
state.error = null;
render();
try {
const [tasks, projects] = await Promise.all([
state.target.type === "view"
? state.client.view(state.target.id)
: state.client.list({ scope: [state.target.id] }),
state.client.projects(),
]);
state.tasks = tasks;
state.projects = projects;
state.error = null;
} catch (e) {
state.error = e instanceof RpcError ? e.message : String(e);
} finally {
state.loading = false;
render();
}
}
function projectTitle(id) {
if (!id) return null;
return state.projects.find((p) => p.id === id)?.title || id;
}
async function refreshProjects() {
try {
state.projects = await state.client.projects();
} catch {
/* keep stale list */
}
}
// --- rendering --------------------------------------------------------------
function render() {
renderHeader();
renderMain();
}
function renderHeader() {
$("#view-title").textContent = state.search ? "Search" : state.target.title;
}
function attentionDot(att) {
return h("span", {
class: "flag",
style: hasFlag(att) ? `color:${ATTENTION_COLORS[att]}` : "color:transparent",
}, hasFlag(att) ? "⚑" : "·");
}
function dateChip(t) {
const now = Date.now();
if (isOverdue(t.late_on, now)) {
return h("span", { class: "chip overdue" }, `late ${fmtRelative(t.late_on, now)}`);
}
if (t.do_date != null) {
return h("span", { class: "chip" }, fmtRelative(t.do_date, now));
}
return null;
}
function taskRow(t) {
const expanded = state.expandedId === t.node_id;
const row = h(
"div",
{ class: "row" + (expanded ? " expanded" : "") },
h(
"div",
{
class: "row-head",
onclick: () => {
state.expandedId = expanded ? null : t.node_id;
render();
if (!expanded) loadPreview(t);
},
},
attentionDot(t.attention),
h("span", { class: "bullet", style: `color:${projectColor(t.project_id)}` }, "●"),
h("span", { class: "title" }, t.title),
t.recurrence && h("span", { class: "recur" }, "↻"),
dateChip(t),
),
expanded && taskDetail(t),
);
return row;
}
function taskDetail(t) {
const meta = [];
if (t.project_id) meta.push(["project", projectTitle(t.project_id)]);
if (t.recurrence) meta.push(["recurs", t.recurrence]);
if (t.do_date != null) meta.push(["do", fmtRelative(t.do_date)]);
if (t.late_on != null) meta.push(["late", fmtRelative(t.late_on)]);
return h(
"div",
{ class: "detail" },
meta.length &&
h(
"div",
{ class: "meta" },
meta.map(([k, v]) => h("div", { class: "meta-row" }, h("span", { class: "meta-k" }, k), h("span", {}, v))),
),
h(
"div",
{ class: "actions" },
actionBtn("✓ Done", () => triage(t, "done")),
actionBtn("⤓ Drop", () => triage(t, "dropped")),
t.recurrence && actionBtn("↻ Skip", () => doSkip(t)),
actionBtn("⚑ Attn", () => cycleAttention(t)),
actionBtn("📅 Date", () => openReschedule(t)),
actionBtn("📁 Move", () => openMove(t)),
actionBtn("🗑 Delete", () => doDelete(t), "danger"),
),
h("pre", { class: "preview", id: `preview-${t.node_id}` }, "…"),
);
}
function actionBtn(label, onclick, extra = "") {
return h("button", { class: `act ${extra}`, onclick }, label);
}
async function loadPreview(t) {
const pre = $(`#preview-${t.node_id}`);
if (!pre) return;
try {
const ctxId = t.canonical_context_id || (await state.client.contextOf(t.node_id));
const [body, log] = await Promise.all([
ctxId ? state.client.nodeBody(ctxId) : Promise.resolve(""),
state.client.logTail(t.node_id, 5).catch(() => []),
]);
const parts = [];
if (body.trim()) parts.push(body.trim());
if (log && log.length) parts.push("— log —\n" + log.join("\n"));
pre.textContent = parts.join("\n\n") || "(no context yet)";
} catch (e) {
pre.textContent = `(could not load context: ${e.message})`;
}
}
function renderMain() {
const main = $("#main");
main.innerHTML = "";
if (state.search) {
main.append(searchPane());
return;
}
if (state.error) {
main.append(h("div", { class: "notice error" }, state.error));
}
if (state.loading && state.tasks.length === 0) {
main.append(h("div", { class: "notice" }, "Loading…"));
return;
}
if (!state.loading && state.tasks.length === 0 && !state.error) {
main.append(h("div", { class: "notice" }, "Nothing here. Tap + to capture a task."));
return;
}
const list = h("div", { class: "list" }, state.tasks.map(taskRow));
main.append(list);
}
// --- drawer (views + projects) ---------------------------------------------
function renderDrawer() {
const body = $("#drawer-body");
body.innerHTML = "";
body.append(h("div", { class: "drawer-section" }, "Views"));
for (const v of VIEWS) {
body.append(drawerItem(v.title, state.target.type === "view" && state.target.id === v.id, () => {
state.target = { type: "view", id: v.id, title: v.title };
closeDrawer();
reload();
}));
}
body.append(h("div", { class: "drawer-section" }, "Projects"));
if (state.projects.length === 0) {
body.append(h("div", { class: "drawer-empty" }, "(none yet)"));
}
for (const p of state.projects) {
body.append(drawerItem(p.title, state.target.type === "project" && state.target.id === p.id, () => {
state.target = { type: "project", id: p.id, title: p.title };
closeDrawer();
reload();
}, projectColor(p.id)));
}
}
function drawerItem(label, active, onclick, dot) {
return h(
"div",
{ class: "drawer-item" + (active ? " active" : ""), onclick },
dot ? h("span", { class: "bullet", style: `color:${dot}` }, "●") : h("span", { class: "bullet" }, " "),
h("span", {}, label),
);
}
function openDrawer() {
renderDrawer();
$("#drawer").classList.add("open");
$("#backdrop").classList.add("show");
}
function closeDrawer() {
$("#drawer").classList.remove("open");
$("#backdrop").classList.remove("show");
}
// --- modal scaffolding ------------------------------------------------------
function openModal(node) {
const root = $("#modal-root");
root.innerHTML = "";
root.append(h("div", { class: "modal-backdrop", onclick: closeModal }, h("div", { class: "modal", onclick: (e) => e.stopPropagation() }, node)));
root.classList.add("show");
}
function closeModal() {
$("#modal-root").classList.remove("show");
$("#modal-root").innerHTML = "";
}
function modalOpen() {
return $("#modal-root").classList.contains("show");
}
// --- quick-add (the primary use case) --------------------------------------
function openQuickAdd() {
closeDrawer();
const input = h("input", {
class: "qa-input",
type: "text",
placeholder: "Buy milk tomorrow p2 #Work every week",
autocomplete: "off",
autocapitalize: "sentences",
enterkeyhint: "done",
});
const preview = h("div", { class: "qa-preview" });
const updatePreview = () => {
const parsed = quickParse(input.value, today(), state.projects);
preview.innerHTML = "";
if (!input.value.trim()) {
preview.append(h("span", { class: "qa-hint" }, "p1p4 · #Project · today/+3d/fri · every week"));
return;
}
preview.append(h("span", { class: "qa-title" }, parsed.title || "(no title)"));
if (parsed.attention) {
preview.append(h("span", { class: "qa-tag", style: `color:${ATTENTION_COLORS[parsed.attention]}` }, "⚑ " + parsed.attention));
}
if (parsed.doDate != null) preview.append(h("span", { class: "qa-tag" }, "📅 " + fmtRelative(parsed.doDate)));
if (parsed.projectId) preview.append(h("span", { class: "qa-tag" }, "📁 " + projectTitle(parsed.projectId)));
if (parsed.recurrence) preview.append(h("span", { class: "qa-tag" }, "↻ " + parsed.recurrence));
};
input.addEventListener("input", updatePreview);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
submitQuickAdd(input.value);
} else if (e.key === "Escape") {
closeModal();
}
});
const mic = voiceButton(input, updatePreview);
const node = h(
"div",
{ class: "qa" },
h("div", { class: "qa-row" }, input, mic),
preview,
h(
"div",
{ class: "qa-foot" },
state.target.type === "project"
? h("span", { class: "qa-dest" }, "→ " + state.target.title)
: h("span", { class: "qa-dest" }, "→ Inbox (unless #Project given)"),
h("button", { class: "qa-add", onclick: () => submitQuickAdd(input.value) }, "Add"),
),
);
openModal(node);
updatePreview();
setTimeout(() => input.focus(), 50);
}
async function submitQuickAdd(raw) {
const text = raw.trim();
if (!text) return;
const parsed = quickParse(text, today(), state.projects);
if (!parsed.title) {
toast("Needs a title.");
return;
}
const projectId =
parsed.projectId || (state.target.type === "project" ? state.target.id : null);
closeModal();
try {
await state.client.createTask({
title: parsed.title,
attention: parsed.attention,
doDate: parsed.doDate,
recurrence: parsed.recurrence,
projectId,
});
toast(`Added: ${parsed.title}`);
reload();
} catch (e) {
toast(`Add failed: ${e.message}`);
}
}
// --- voice input ------------------------------------------------------------
// Web Speech API where available (desktop Chrome, Android). On iOS Safari the
// API is absent, but the on-screen keyboard's dictation mic works in the text
// field for free — so we simply omit the button there.
function voiceButton(input, onUpdate) {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) return null;
let rec = null;
const btn = h("button", { class: "qa-mic", title: "Dictate" }, "🎤");
btn.addEventListener("click", () => {
if (rec) {
rec.stop();
return;
}
rec = new SR();
rec.lang = navigator.language || "en-US";
rec.interimResults = true;
btn.classList.add("listening");
let base = input.value ? input.value + " " : "";
rec.onresult = (ev) => {
let text = "";
for (const r of ev.results) text += r[0].transcript;
input.value = base + text;
onUpdate();
};
rec.onend = () => {
rec = null;
btn.classList.remove("listening");
input.focus();
};
rec.onerror = () => toast("Voice input unavailable.");
rec.start();
});
return btn;
}
// --- reschedule -------------------------------------------------------------
function openReschedule(t) {
const input = h("input", {
class: "qa-input",
type: "text",
placeholder: "today · tomorrow · +3d · fri · 2026-07-01 · (blank to clear)",
value: t.do_date != null ? fmtRelative(t.do_date) : "",
autocomplete: "off",
enterkeyhint: "done",
});
const apply = async () => {
const v = input.value.trim();
let doDate = null;
if (v) {
try {
doDate = toEpochMs(parseDate(v, today()));
} catch {
toast("Unrecognized date.");
return;
}
}
closeModal();
try {
await state.client.setSchedule(t.node_id, { doDate });
toast(doDate ? `Rescheduled: ${fmtRelative(doDate)}` : "Do-date cleared");
reload();
} catch (e) {
toast(`Failed: ${e.message}`);
}
};
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") (e.preventDefault(), apply());
if (e.key === "Escape") closeModal();
});
openModal(
h(
"div",
{ class: "qa" },
h("div", { class: "modal-title" }, "Reschedule"),
input,
h("div", { class: "qa-foot" }, h("button", { class: "qa-add", onclick: apply }, "Set")),
),
);
setTimeout(() => input.focus(), 50);
}
// --- move / project picker --------------------------------------------------
function openMove(t) {
const filter = h("input", { class: "qa-input", type: "text", placeholder: "Filter or new project…", autocomplete: "off" });
const list = h("div", { class: "picker-list" });
const renderOptions = () => {
const q = filter.value.trim().toLowerCase();
list.innerHTML = "";
list.append(pickerItem("(Unfile)", () => move(t, null)));
for (const p of state.projects) {
if (q && !p.title.toLowerCase().includes(q)) continue;
list.append(pickerItem(p.title, () => move(t, p.id), projectColor(p.id)));
}
const exact = state.projects.some((p) => p.title.toLowerCase() === q);
if (q && !exact) {
list.append(pickerItem(`+ New project "${filter.value.trim()}"`, () => createAndMove(t, filter.value.trim())));
}
};
filter.addEventListener("input", renderOptions);
filter.addEventListener("keydown", (e) => e.key === "Escape" && closeModal());
openModal(h("div", { class: "qa" }, h("div", { class: "modal-title" }, `Move "${t.title}"`), filter, list));
renderOptions();
setTimeout(() => filter.focus(), 50);
}
function pickerItem(label, onclick, dot) {
return h(
"div",
{ class: "picker-item", onclick },
dot ? h("span", { class: "bullet", style: `color:${dot}` }, "●") : h("span", { class: "bullet" }, " "),
h("span", {}, label),
);
}
async function move(t, projectId) {
closeModal();
try {
await state.client.setProject(t.node_id, projectId);
toast(projectId ? `Moved to ${projectTitle(projectId)}` : "Unfiled");
reload();
} catch (e) {
toast(`Failed: ${e.message}`);
}
}
async function createAndMove(t, name) {
closeModal();
try {
const id = await state.client.createProject(name);
await refreshProjects();
await state.client.setProject(t.node_id, id);
toast(`Moved to ${name}`);
reload();
} catch (e) {
toast(`Failed: ${e.message}`);
}
}
// --- triage actions ---------------------------------------------------------
async function triage(t, newState) {
state.expandedId = null;
try {
await state.client.setState(t.node_id, newState);
const verb = newState === "done" ? "Done" : "Dropped";
toast(`${verb}: ${t.title}`, {
label: "Undo",
run: async () => {
try {
await state.client.setState(t.node_id, "outstanding");
reload();
} catch (e) {
toast(`Undo failed: ${e.message}`);
}
},
});
reload();
} catch (e) {
toast(`Failed: ${e.message}`);
}
}
async function doSkip(t) {
try {
await state.client.skip(t.node_id);
toast(`Skipped: ${t.title}`);
reload();
} catch (e) {
toast(`Failed: ${e.message}`);
}
}
async function cycleAttention(t) {
const next = nextAttention(t.attention);
try {
await state.client.setAttention(t.node_id, next);
toast(`Attention: ${next}`);
reload();
} catch (e) {
toast(`Failed: ${e.message}`);
}
}
async function doDelete(t) {
if (!confirm(`Delete "${t.title}"? This removes it for good (use Drop to triage instead).`)) {
return;
}
state.expandedId = null;
try {
await state.client.tombstone(t.node_id);
toast(`Deleted: ${t.title}`);
reload();
} catch (e) {
toast(`Failed: ${e.message}`);
}
}
// --- search -----------------------------------------------------------------
function openSearch() {
state.search = { query: "", results: [] };
render();
setTimeout(() => $("#search-input")?.focus(), 50);
}
function closeSearch() {
state.search = null;
render();
}
function searchPane() {
const input = h("input", {
id: "search-input",
class: "search-input",
type: "search",
placeholder: "Search tasks & docs…",
value: state.search.query,
autocomplete: "off",
enterkeyhint: "search",
});
let timer = null;
const run = async () => {
state.search.query = input.value;
const q = input.value.trim();
if (!q) {
state.search.results = [];
renderSearchResults();
return;
}
try {
state.search.results = await state.client.search(q);
} catch (e) {
state.search.results = [];
toast(e.message);
}
renderSearchResults();
};
input.addEventListener("input", () => {
clearTimeout(timer);
timer = setTimeout(run, 200);
});
input.addEventListener("keydown", (e) => e.key === "Escape" && closeSearch());
return h(
"div",
{ class: "search-pane" },
h("div", { class: "search-bar" }, input, h("button", { class: "search-close", onclick: closeSearch }, "✕")),
h("div", { class: "search-results", id: "search-results" }),
);
}
function renderSearchResults() {
const root = $("#search-results");
if (!root) return;
root.innerHTML = "";
if (!state.search.results.length) {
root.append(h("div", { class: "notice" }, state.search.query ? "No matches." : "Type to search."));
return;
}
for (const hit of state.search.results) {
root.append(
h(
"div",
{ class: "search-hit" },
h("span", { class: "hit-kind" }, `[${hit.kind}]`),
h("span", {}, hit.title),
),
);
}
}
// --- settings ---------------------------------------------------------------
function openSettings() {
const url = h("input", { class: "qa-input", type: "url", placeholder: "https://hub.example.com:8787", value: state.settings.baseUrl, autocomplete: "off", inputmode: "url" });
const tok = h("input", { class: "qa-input", type: "password", placeholder: "Bearer token (optional)", value: state.settings.token, autocomplete: "off" });
const test = h("div", { class: "settings-test" });
const save = async () => {
state.settings.baseUrl = url.value.trim();
state.settings.token = tok.value.trim();
saveSettings(state.settings);
state.client = new Client(state.settings);
closeModal();
reload();
};
const check = async () => {
test.textContent = "Checking…";
const probe = new Client({ baseUrl: url.value.trim(), token: tok.value.trim() });
try {
const v = await probe.call("version", {});
test.textContent = `✓ Connected (hephd ${v.version})`;
test.className = "settings-test ok";
} catch (e) {
test.textContent = `${e.message}`;
test.className = "settings-test bad";
}
};
openModal(
h(
"div",
{ class: "qa" },
h("div", { class: "modal-title" }, "Settings"),
h("label", { class: "settings-label" }, "Hub URL"),
url,
h("label", { class: "settings-label" }, "Token"),
tok,
test,
h(
"div",
{ class: "qa-foot settings-foot" },
h("button", { class: "act", onclick: check }, "Test"),
h("button", { class: "qa-add", onclick: save }, "Save"),
),
h("div", { class: "settings-hint" }, "The hub is your server-mode hephd. Leave the token blank if the hub runs without OIDC."),
),
);
}
// --- keyboard ---------------------------------------------------------------
function onKeydown(e) {
const typing = ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement?.tagName);
// Cmd/Ctrl + ' opens quick-add anywhere (mirrors the global popover).
if ((e.metaKey || e.ctrlKey) && e.key === "'") {
e.preventDefault();
openQuickAdd();
return;
}
if (typing || modalOpen()) {
if (e.key === "Escape" && modalOpen()) closeModal();
return;
}
if (e.key === "a") (e.preventDefault(), openQuickAdd());
else if (e.key === "/") (e.preventDefault(), openSearch());
else if (e.key === "r") reload();
else if (e.key === "Escape") state.search ? closeSearch() : closeDrawer();
}
// --- shell + init -----------------------------------------------------------
function buildShell() {
const app = $("#app");
app.append(
h(
"header",
{ class: "appbar" },
h("button", { class: "icon-btn", title: "Menu", onclick: openDrawer }, "☰"),
h("div", { id: "view-title", class: "appbar-title" }, state.target.title),
h("button", { class: "icon-btn", title: "Search", onclick: openSearch }, "🔍"),
h("button", { class: "icon-btn", title: "Settings", onclick: openSettings }, "⚙"),
),
h("main", { id: "main" }),
h("button", { id: "fab", class: "fab", title: "Quick add (Cmd-)", onclick: openQuickAdd }, "+"),
h("div", { id: "backdrop", class: "backdrop", onclick: closeDrawer }),
h(
"aside",
{ id: "drawer", class: "drawer" },
h("div", { class: "drawer-head" }, "heph"),
h("div", { id: "drawer-body", class: "drawer-body" }),
),
h("div", { id: "modal-root", class: "modal-root" }),
h("div", { id: "toast", class: "toast" }),
);
}
async function init() {
buildShell();
document.addEventListener("keydown", onKeydown);
render();
reload();
if ("serviceWorker" in navigator) {
try {
await navigator.serviceWorker.register("./sw.js");
} catch {
/* offline shell is best-effort */
}
}
}
init();

71
heph-pwa/src/fmt.js Normal file
View file

@ -0,0 +1,71 @@
// Display helpers — the PWA mirror of heph-tui's fmt.rs: relative date chips,
// attention colors/flags, and a stable per-project bullet color.
/** Attention color string → the CSS custom-property color used for flags/dots. */
export const ATTENTION_COLORS = {
red: "var(--att-red)",
orange: "var(--att-orange)",
blue: "var(--att-blue)",
white: "var(--att-white)",
};
/** The cycle order used by the attention toggle (matches the TUI's `A` key). */
export const ATTENTION_CYCLE = [null, "white", "orange", "red", "blue"];
/** Next attention in the cycle: none → white → orange → red → blue → white. */
export function nextAttention(att) {
const i = ATTENTION_CYCLE.indexOf(att ?? null);
// After blue (last), wrap to white (index 1), not back to none.
const next = i < 0 ? 1 : (i + 1) % ATTENTION_CYCLE.length;
return ATTENTION_CYCLE[next === 0 ? 1 : next] ?? "white";
}
/** Whether an attention band shows a flag glyph (red/orange/blue; not white). */
export function hasFlag(att) {
return att === "red" || att === "orange" || att === "blue";
}
function startOfDay(ms) {
const d = new Date(ms);
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
}
/**
* Compact, relative date label for an epoch-ms date (heph-tui fmt.rs):
* today/tomorrow/yesterday, else MM-DD within the current year, else YYYY-MM-DD.
*/
export function fmtRelative(ms, nowMs = Date.now()) {
if (ms == null) return "";
const day = startOfDay(ms);
const today = startOfDay(nowMs);
const oneDay = 86_400_000;
if (day === today) return "today";
if (day === today + oneDay) return "tomorrow";
if (day === today - oneDay) return "yesterday";
const d = new Date(ms);
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
if (d.getFullYear() === new Date(nowMs).getFullYear()) return `${mm}-${dd}`;
return `${d.getFullYear()}-${mm}-${dd}`;
}
/** True when `lateOn` is strictly in the past — the sole urgency signal (§7). */
export function isOverdue(lateOn, nowMs = Date.now()) {
return lateOn != null && nowMs > lateOn;
}
/** A stable hue (0359) for a project id, so its bullet color is deterministic. */
export function projectHue(id) {
if (!id) return null;
let h = 0;
for (let i = 0; i < id.length; i++) {
h = (h * 31 + id.charCodeAt(i)) >>> 0;
}
return h % 360;
}
/** CSS color for a project's bullet, or a neutral default when unfiled. */
export function projectColor(id) {
const hue = projectHue(id);
return hue == null ? "var(--bullet-none)" : `hsl(${hue} 55% 62%)`;
}

163
heph-pwa/src/rpc.js Normal file
View file

@ -0,0 +1,163 @@
// hephd JSON-RPC-over-HTTP client for the PWA. The PWA is a thin, online-only
// client (no local CRDT replica): every read and write is a POST to the hub's
// `/rpc` endpoint, exactly mirroring heph-tui's socket Backend (backend.rs).
//
// Connection settings (hub base URL + optional bearer token) live in
// localStorage so the install remembers them across launches.
const SETTINGS_KEY = "heph-pwa:settings";
export function loadSettings() {
try {
const s = JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}");
return { baseUrl: s.baseUrl || "", token: s.token || "" };
} catch {
return { baseUrl: "", token: "" };
}
}
export function saveSettings(settings) {
localStorage.setItem(
SETTINGS_KEY,
JSON.stringify({ baseUrl: settings.baseUrl || "", token: settings.token || "" }),
);
}
/** Thrown for transport/auth/method failures, carrying an HTTP-ish status. */
export class RpcError extends Error {
constructor(message, status = 0) {
super(message);
this.name = "RpcError";
this.status = status;
}
}
export class Client {
constructor(settings) {
this.settings = settings;
}
get configured() {
return !!this.settings.baseUrl;
}
/** Low-level call: returns the `result` value, or throws RpcError. */
async call(method, params = {}) {
if (!this.configured) {
throw new RpcError("No hub configured — open Settings and set the hub URL.", 0);
}
const base = this.settings.baseUrl.replace(/\/+$/, "");
const headers = { "Content-Type": "application/json" };
if (this.settings.token) headers["Authorization"] = `Bearer ${this.settings.token}`;
let resp;
try {
resp = await fetch(`${base}/rpc`, {
method: "POST",
headers,
body: JSON.stringify({ method, params }),
});
} catch (e) {
throw new RpcError(`Cannot reach hub at ${base} (${e.message}).`, 0);
}
if (resp.status === 401) throw new RpcError("Unauthorized — set or refresh your token.", 401);
if (resp.status === 403) throw new RpcError("Forbidden — this token does not own the hub.", 403);
if (!resp.ok) throw new RpcError(`Hub returned HTTP ${resp.status}.`, resp.status);
const body = await resp.json();
if (body.error) throw new RpcError(body.error.message || "RPC error", 200);
return body.result;
}
// --- Reads (mirror heph-tui's Backend) ---------------------------------
/** Built-in named view (tom|tasks|work|chores|ondeck|inbox) → RankedTask[]. */
view(name) {
return this.call("view", { name });
}
/** Raw filter listing → RankedTask[]. */
list(filter) {
return this.call("list", filter);
}
/** Projects, title-sorted → [{ id, title }]. */
async projects() {
const nodes = await this.call("node.list", { kind: "project" });
return nodes.map((n) => ({ id: n.id, title: n.title }));
}
async nodeBody(id) {
const node = await this.call("node.get", { id });
return node && node.body ? node.body : "";
}
logTail(taskId, n = 5) {
return this.call("log.tail", { task_id: taskId, n });
}
/** Full-text search → [{ id, title, kind }]. */
async search(query) {
const nodes = await this.call("search", { query });
return nodes.map((n) => ({ id: n.id, title: n.title, kind: n.kind }));
}
health() {
return this.call("health", {});
}
// --- Writes ------------------------------------------------------------
/** Create a task. attention/doDate/recurrence/projectId may be null. */
createTask({ title, attention = null, doDate = null, recurrence = null, projectId = null }) {
return this.call("task.create", {
title,
attention,
do_date: doDate,
recurrence,
project_id: projectId,
});
}
setState(id, state) {
return this.call("task.set_state", { id, state });
}
setAttention(id, attention) {
return this.call("task.set_attention", { id, attention });
}
/** Patch schedule scalars. Pass undefined to leave a field unchanged; pass
* null to clear it; pass a value to set it (double-option semantics). */
setSchedule(id, patch) {
const params = { id };
if ("doDate" in patch) params.do_date = patch.doDate;
if ("lateOn" in patch) params.late_on = patch.lateOn;
if ("recurrence" in patch) params.recurrence = patch.recurrence;
return this.call("task.set_schedule", params);
}
setProject(id, projectId) {
return this.call("task.set_project", { id, project_id: projectId });
}
skip(id) {
return this.call("task.skip", { id });
}
tombstone(id) {
return this.call("node.tombstone", { id });
}
async createProject(title) {
const node = await this.call("node.create", { kind: "project", title });
return node.id;
}
/** The canonical context doc id for a task, if any (links.outgoing). */
async contextOf(taskId) {
const links = await this.call("links.outgoing", { id: taskId });
const ctx = links.find((l) => l.link_type === "canonical-context");
return ctx ? ctx.dst_id : null;
}
}

504
heph-pwa/styles.css Normal file
View file

@ -0,0 +1,504 @@
/* heph-pwa — a dark, terminal-flavored mirror of heph-tui, tuned for touch. */
:root {
--bg: #15181d;
--bg-elev: #1c2027;
--bg-row: #1a1e24;
--border: #2a2f38;
--fg: #e6e9ef;
--fg-dim: #8b94a3;
--accent: #6db3f2;
--att-red: #ff6b6b;
--att-orange: #ffb454;
--att-blue: #6db3f2;
--att-white: #e6e9ef;
--bullet-none: #5a6373;
--danger: #ff6b6b;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html,
body {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--fg);
font: 16px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
overscroll-behavior-y: none;
}
#app {
display: flex;
flex-direction: column;
height: 100%;
}
/* --- App bar --- */
.appbar {
display: flex;
align-items: center;
gap: 4px;
padding: calc(var(--safe-top) + 6px) 8px 6px;
background: var(--bg-elev);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 5;
}
.appbar-title {
flex: 1;
font-weight: 600;
font-size: 18px;
padding-left: 4px;
}
.icon-btn {
background: none;
border: 0;
color: var(--fg);
font-size: 20px;
width: 40px;
height: 40px;
border-radius: 8px;
}
.icon-btn:active {
background: var(--bg-row);
}
/* --- Main / list --- */
#main {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: calc(var(--safe-bottom) + 96px);
}
.notice {
padding: 32px 20px;
text-align: center;
color: var(--fg-dim);
}
.notice.error {
color: var(--att-orange);
}
.list {
display: flex;
flex-direction: column;
}
.row {
border-bottom: 1px solid var(--border);
}
.row-head {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
min-height: 28px;
}
.row.expanded {
background: var(--bg-row);
}
.flag {
width: 14px;
text-align: center;
flex: 0 0 auto;
}
.bullet {
flex: 0 0 auto;
font-size: 12px;
}
.title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row.expanded .title {
white-space: normal;
}
.recur {
color: #c678dd;
flex: 0 0 auto;
}
.chip {
flex: 0 0 auto;
font-size: 13px;
color: var(--fg-dim);
font-variant-numeric: tabular-nums;
}
.chip.overdue {
color: var(--att-red);
font-weight: 700;
}
/* --- Task detail --- */
.detail {
padding: 4px 14px 14px 36px;
}
.meta {
margin-bottom: 10px;
font-size: 13px;
color: var(--fg-dim);
}
.meta-row {
display: flex;
gap: 8px;
}
.meta-k {
width: 64px;
flex: 0 0 auto;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.act {
background: var(--bg-elev);
border: 1px solid var(--border);
color: var(--fg);
border-radius: 8px;
padding: 8px 10px;
font-size: 14px;
}
.act:active {
background: var(--border);
}
.act.danger {
color: var(--danger);
border-color: #4a2a2a;
}
.preview {
margin: 12px 0 0;
padding: 10px;
background: #0f1216;
border: 1px solid var(--border);
border-radius: 8px;
font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--fg-dim);
white-space: pre-wrap;
word-break: break-word;
max-height: 240px;
overflow-y: auto;
}
/* --- FAB --- */
.fab {
position: fixed;
right: 18px;
bottom: calc(var(--safe-bottom) + 18px);
width: 60px;
height: 60px;
border-radius: 30px;
border: 0;
background: var(--accent);
color: #0c1014;
font-size: 34px;
line-height: 1;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
z-index: 6;
}
.fab:active {
transform: scale(0.95);
}
/* --- Drawer --- */
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
pointer-events: none;
transition: opacity 0.18s;
z-index: 9;
}
.backdrop.show {
opacity: 1;
pointer-events: auto;
}
.drawer {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 78%;
max-width: 320px;
background: var(--bg-elev);
border-right: 1px solid var(--border);
transform: translateX(-100%);
transition: transform 0.2s ease;
z-index: 10;
display: flex;
flex-direction: column;
}
.drawer.open {
transform: translateX(0);
}
.drawer-head {
padding: calc(var(--safe-top) + 16px) 16px 12px;
font-weight: 700;
font-size: 20px;
border-bottom: 1px solid var(--border);
}
.drawer-body {
overflow-y: auto;
padding-bottom: var(--safe-bottom);
}
.drawer-section {
padding: 14px 16px 6px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--fg-dim);
}
.drawer-empty {
padding: 4px 16px 8px;
color: var(--fg-dim);
font-size: 14px;
}
.drawer-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
}
.drawer-item.active {
background: var(--bg-row);
box-shadow: inset 3px 0 0 var(--accent);
}
.drawer-item:active {
background: var(--border);
}
/* --- Modals --- */
.modal-root {
position: fixed;
inset: 0;
z-index: 20;
display: none;
}
.modal-root.show {
display: block;
}
.modal-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: flex-start;
justify-content: center;
padding: calc(var(--safe-top) + 12vh) 12px 12px;
}
.modal {
width: 100%;
max-width: 560px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 14px;
padding: 14px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.modal-title {
font-weight: 600;
margin-bottom: 10px;
}
.qa {
display: flex;
flex-direction: column;
gap: 10px;
}
.qa-row {
display: flex;
gap: 8px;
align-items: center;
}
.qa-input,
.search-input {
flex: 1;
width: 100%;
background: #0f1216;
border: 1px solid var(--border);
color: var(--fg);
border-radius: 10px;
padding: 13px 12px;
font-size: 17px; /* ≥16px so iOS doesn't zoom on focus */
}
.qa-input:focus,
.search-input:focus {
outline: none;
border-color: var(--accent);
}
.qa-mic {
flex: 0 0 auto;
width: 46px;
height: 46px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg-row);
font-size: 20px;
}
.qa-mic.listening {
border-color: var(--att-red);
animation: pulse 1s infinite;
}
@keyframes pulse {
50% {
background: #3a2326;
}
}
.qa-preview {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
min-height: 22px;
}
.qa-hint {
color: var(--fg-dim);
font-size: 13px;
}
.qa-title {
font-weight: 600;
}
.qa-tag {
font-size: 13px;
color: var(--fg-dim);
background: var(--bg-row);
border: 1px solid var(--border);
border-radius: 6px;
padding: 2px 6px;
}
.qa-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.qa-dest {
color: var(--fg-dim);
font-size: 13px;
}
.qa-add {
background: var(--accent);
color: #0c1014;
border: 0;
border-radius: 10px;
padding: 11px 22px;
font-size: 16px;
font-weight: 600;
}
.picker-list {
max-height: 50vh;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.picker-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 6px;
border-bottom: 1px solid var(--border);
}
.picker-item:active {
background: var(--bg-row);
}
.settings-label {
font-size: 13px;
color: var(--fg-dim);
margin-bottom: -4px;
}
.settings-foot {
justify-content: flex-end;
}
.settings-test {
font-size: 13px;
min-height: 18px;
color: var(--fg-dim);
}
.settings-test.ok {
color: #7ec77e;
}
.settings-test.bad {
color: var(--att-red);
}
.settings-hint {
font-size: 12px;
color: var(--fg-dim);
}
/* --- Search --- */
.search-pane {
display: flex;
flex-direction: column;
height: 100%;
}
.search-bar {
display: flex;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.search-close {
background: var(--bg-row);
border: 1px solid var(--border);
color: var(--fg);
border-radius: 10px;
width: 46px;
font-size: 18px;
}
.search-results {
overflow-y: auto;
}
.search-hit {
display: flex;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
}
.hit-kind {
color: var(--fg-dim);
font: 13px ui-monospace, monospace;
flex: 0 0 auto;
}
/* --- Toast --- */
.toast {
position: fixed;
left: 0;
right: 0;
bottom: calc(var(--safe-bottom) + 90px);
display: flex;
justify-content: center;
z-index: 30;
pointer-events: none;
padding: 0 12px;
}
.toast-body {
pointer-events: auto;
background: #2a2f38;
color: var(--fg);
border-radius: 10px;
padding: 11px 14px;
display: flex;
align-items: center;
gap: 14px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5);
max-width: 560px;
}
.toast-action {
background: none;
border: 0;
color: var(--accent);
font-weight: 700;
font-size: 15px;
}

53
heph-pwa/sw.js Normal file
View file

@ -0,0 +1,53 @@
// Service worker: cache the app shell so heph launches offline. Data is never
// cached — every /rpc call must hit the live hub (and POSTs aren't cacheable
// anyway). Bump CACHE when shell assets change to evict the old set.
const CACHE = "heph-pwa-v1";
const SHELL = [
"./",
"./index.html",
"./styles.css",
"./manifest.webmanifest",
"./src/app.js",
"./src/rpc.js",
"./src/quickadd.js",
"./src/datespec.js",
"./src/fmt.js",
"./icons/icon.svg",
];
self.addEventListener("install", (e) => {
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
});
self.addEventListener("activate", (e) => {
e.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (e) => {
const req = e.request;
// Only cache same-origin GETs (the shell). Everything else (RPC, cross-origin)
// goes straight to the network.
if (req.method !== "GET" || new URL(req.url).origin !== self.location.origin) {
return;
}
e.respondWith(
caches.match(req).then(
(hit) =>
hit ||
fetch(req)
.then((resp) => {
if (resp.ok) {
const copy = resp.clone();
caches.open(CACHE).then((c) => c.put(req, copy));
}
return resp;
})
.catch(() => caches.match("./index.html")),
),
);
});