tilemon 0.0.1

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 ADDED
@@ -0,0 +1,102 @@
1
+ # Tilemon
2
+
3
+ A priority board where **importance is space**. The canvas is fixed, so making one
4
+ thing bigger shrinks everything else — a zero-sum map that won't let you pretend ten
5
+ things are all urgent. Hand tasks to your agents; when one gets stuck, it glows.
6
+
7
+ ```
8
+ npx tilemon ./state.json
9
+ ```
10
+
11
+ …boots a local server, serves the board at `http://localhost:4000`, and reads/writes
12
+ your JSON file. Point an always-on monitor at it. Agents flag status over HTTP; you own
13
+ the weights by dragging tiles.
14
+
15
+ ## The idea
16
+
17
+ - **Area = importance.** A tile's on-screen area is its share of attention. The total
18
+ never grows, so weighting one thing up *takes* space from its siblings. You spend
19
+ importance like a budget.
20
+ - **Weight is yours; status is the agent's.** You set importance by dragging (or the
21
+ size slider), deliberately. Agents set status — `todo → in_progress → blocked → done`.
22
+ - **Any item can carry a status, at any depth.** It's a uniformly recursive tree — an
23
+ item may contain items *and* hold its own status. A whole group can be `blocked` (the
24
+ branch is stuck) without lying about a child.
25
+ - **Status renders as heat.** `blocked` glows; `done` drops off the board. An item's own
26
+ status sets a heat *floor*, and heat also rolls up area-weighted from its contents — so
27
+ a stuck thing deep in a group makes the whole group glow, visible from across the room.
28
+ - **`done` is reversible.** Finished items drop off so the board shows live work, but the
29
+ **done** toggle brings them back (dimmed) so you can re-open one — hiding never means
30
+ losing.
31
+
32
+ ## How agents update it
33
+
34
+ One POST. It can *only* change an item's status — never your weights or structure, by
35
+ construction (it's a different endpoint with no access to weight).
36
+
37
+ ```bash
38
+ node examples/flag.mjs build-office.design-office-in-sketchup blocked
39
+ # or directly:
40
+ curl -X POST http://localhost:4000/api/status \
41
+ -H 'content-type: application/json' \
42
+ -d '{"path":"build-office.design-office-in-sketchup","status":"blocked"}'
43
+ ```
44
+
45
+ ## File format
46
+
47
+ ```jsonc
48
+ {
49
+ "name": "Priorities",
50
+ "children": [
51
+ { "id": "work", "name": "Work", "weight": 2, "children": [
52
+ { "id": "ship", "name": "Ship v2", "weight": 3, "status": "in_progress" }
53
+ ]}
54
+ ]
55
+ }
56
+ ```
57
+
58
+ Any node may carry a `status`; a node with children also rolls up heat from them (its own
59
+ status sets the floor). `id` is a stable string agents address by (dotted: `work.ship`);
60
+ `name` can change freely. Edit the file directly and the board live-updates — the server
61
+ watches it.
62
+
63
+ ## Routes
64
+
65
+ | Route | Who | Does |
66
+ |---|---|---|
67
+ | `GET /` | — | the board |
68
+ | `GET /api/state` | — | current tree |
69
+ | `GET /api/events` | — | Server-Sent Events; `change` on any write |
70
+ | `POST /api/status` | **agents** | `{path, status}` — status only, any node |
71
+ | `POST /api/weight` | **you / UI** | `{path, weight}` — weight only |
72
+
73
+ Set `TILEMON_TOKEN` to require `Authorization: Bearer <token>` on the write routes
74
+ before exposing the port beyond a trusted network.
75
+
76
+ ## The renderer is reusable
77
+
78
+ `board.js` is a framework-agnostic ES module that owns no data — you hand it a tree and
79
+ two callbacks:
80
+
81
+ ```js
82
+ import { mount } from 'tilemon/board.js';
83
+ const board = mount(boardEl, controlsEl, {
84
+ state,
85
+ onWeightChange: (path, weight) => { /* persist however you like */ },
86
+ onStatusChange: (path, status) => { /* persist however you like */ },
87
+ });
88
+ board.update(newState); // re-render; drill level + selection preserved
89
+ ```
90
+
91
+ The npx tool wires those callbacks to `POST`. A hosted app would wire the same callbacks
92
+ to a database — the renderer doesn't change. See [`SPEC.md`](./SPEC.md) for the full
93
+ design, architecture, and roadmap.
94
+
95
+ ## Develop
96
+
97
+ ```bash
98
+ node server.mjs ./state.sample.json # boot against the sample
99
+ npm test # headless renderer checks (no browser needed)
100
+ ```
101
+
102
+ MIT.
package/board.js ADDED
@@ -0,0 +1,346 @@
1
+ // board.js — Tilemon renderer. Framework-agnostic, zero dependencies, ESM.
2
+ //
3
+ // import { mount } from './board.js'
4
+ // const board = mount(boardEl, controlsEl, {
5
+ // state, // the tree (see SPEC §4.1)
6
+ // onWeightChange: (path, weight) => {}, // human resized a node -> persist
7
+ // onStatusChange: (path, status) => {}, // a leaf's status changed -> persist
8
+ // })
9
+ // board.update(newState) // re-render from fresh state (drill/selection preserved)
10
+ //
11
+ // The renderer owns NO storage. It announces *what changed* via callbacks; the host
12
+ // decides where that goes (a file via POST in the npx tool, a DB in the hosted app).
13
+ // That seam is the whole reason this is a separate module — see SPEC §3.
14
+ //
15
+ // Data model: a node is { id, name, weight, children } (parent) or
16
+ // { id, name, weight, status } (leaf). status ∈ todo|in_progress|blocked|done.
17
+ // `done` leaves drop off the board; status maps to heat for colour; heat rolls up
18
+ // area-weighted through parents. Weight is the node's share vs. its siblings.
19
+
20
+ const PAD = 5, HEADER = 22, INSET = 2;
21
+ const STATUS_HEAT = { todo: 0, in_progress: 0.5, blocked: 1 }; // `done` => dropped, no heat
22
+ const STATUSES = ['todo', 'in_progress', 'blocked', 'done'];
23
+ const DONE_COLOR = 'rgb(74,92,58)'; // muted sage — "complete", off the heat ramp, fits the warm-dark palette
24
+
25
+ const STYLE = `
26
+ .tlm-board{position:relative;width:100%;height:100%;overflow:hidden;background:var(--tlm-bg,#14120D);touch-action:none}
27
+ .tlm-board .tile{position:absolute;border-radius:5px;overflow:hidden;cursor:grab;user-select:none;outline:0 solid transparent;
28
+ transition:left .18s ease,top .18s ease,width .18s ease,height .18s ease,background .25s ease,opacity .2s ease}
29
+ .tlm-board.nodrag .tile{transition:background .25s ease}
30
+ .tlm-board .tile:active{cursor:grabbing}
31
+ .tlm-board .tile.sel{outline:2px solid var(--tlm-gold,#E8C56A);outline-offset:-2px;z-index:600!important}
32
+ .tlm-board .tile.armed{outline:2px dashed var(--tlm-gold,#E8C56A);outline-offset:-2px;z-index:600!important}
33
+ .tlm-board .tile .hd{position:absolute;top:0;left:0;right:0;height:22px;display:flex;align-items:center;
34
+ justify-content:space-between;padding:0 8px;gap:6px;pointer-events:none}
35
+ .tlm-board .tile .leaf{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;
36
+ text-align:center;padding:6px;gap:3px;pointer-events:none}
37
+ .tlm-board .nm{font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
38
+ .tlm-board .wt{font-family:"Space Mono",monospace;font-size:10.5px;opacity:.6;white-space:nowrap}
39
+ .tlm-board .tile.done{opacity:.6}
40
+ .tlm-board .tile.hot{animation:tlm-pulse 1.7s ease-in-out infinite}
41
+ @keyframes tlm-pulse{0%,100%{filter:brightness(1)}50%{filter:brightness(1.22)}}
42
+ @media (prefers-reduced-motion:reduce){.tlm-board .tile.hot{animation:none}}
43
+ .tlm-controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap;min-height:30px}
44
+ .tlm-controls .crumb{font-size:14px;color:var(--tlm-dim,#9A9182);display:flex;align-items:center;gap:6px;flex:1;min-width:180px}
45
+ .tlm-controls .crumb b{color:var(--tlm-ink,#ECE7DA);font-weight:500}
46
+ .tlm-controls .crumb span.c{cursor:pointer}.tlm-controls .crumb span.c:hover{color:var(--tlm-gold,#E8C56A)}
47
+ .tlm-controls .crumb .sep{color:var(--tlm-line,#2A2820)}
48
+ .tlm-controls button,.tlm-controls .tog{font-family:"Space Mono",monospace;font-size:11.5px;background:var(--tlm-panel,#1C1A14);
49
+ color:var(--tlm-ink,#ECE7DA);border:1px solid var(--tlm-line,#2A2820);border-radius:6px;padding:6px 9px;cursor:pointer;
50
+ transition:border-color .15s,color .15s,opacity .15s}
51
+ .tlm-controls button:hover:not(:disabled){border-color:var(--tlm-gold,#E8C56A);color:var(--tlm-gold,#E8C56A)}
52
+ .tlm-controls button:disabled{opacity:.32;cursor:default}
53
+ .tlm-controls button.on{border-color:var(--tlm-gold,#E8C56A);color:var(--tlm-gold,#E8C56A)}
54
+ .tlm-controls .grp{display:flex;gap:4px;align-items:center}
55
+ .tlm-controls .grp .lbl{font-family:"Space Mono",monospace;font-size:10.5px;color:var(--tlm-dim,#9A9182);margin-right:2px}
56
+ .tlm-sizebar{display:flex;align-items:center;gap:10px;background:var(--tlm-panel,#1C1A14);border:1px solid var(--tlm-line,#2A2820);
57
+ border-radius:8px;padding:7px 12px;font-family:"Space Mono",monospace;font-size:11.5px;color:var(--tlm-dim,#9A9182);width:100%}
58
+ .tlm-sizebar b{color:var(--tlm-ink,#ECE7DA);font-weight:400}
59
+ .tlm-sizebar input[type=range]{flex:1;min-width:120px;accent-color:var(--tlm-gold,#E8C56A)}
60
+ .tlm-sizebar .pct{color:var(--tlm-gold,#E8C56A);min-width:38px;text-align:right}
61
+ `;
62
+
63
+ function injectStyle(doc) {
64
+ if (doc.getElementById('tilemon-style')) return;
65
+ const s = doc.createElement('style');
66
+ s.id = 'tilemon-style';
67
+ s.textContent = STYLE;
68
+ doc.head.appendChild(s);
69
+ }
70
+
71
+ export function mount(boardEl, controlsEl, opts = {}) {
72
+ const onWeightChange = opts.onWeightChange || (() => {});
73
+ const onStatusChange = opts.onStatusChange || (() => {});
74
+ injectStyle(boardEl.ownerDocument);
75
+ boardEl.classList.add('tlm-board');
76
+ if (controlsEl) controlsEl.classList.add('tlm-controls');
77
+
78
+ let srcState = opts.state || { name: 'Priorities', children: [] };
79
+ let root, viewRoot;
80
+ let viewRootId = null, selId = null, showWeights = false, showDone = !!opts.showDone;
81
+ let tileEls = {}, drag = null, freeze = false;
82
+
83
+ // ---- derive the working tree from source state ----
84
+ // Any node (at any depth) may carry a status — the tree is uniformly recursive, not
85
+ // leaf-only. A node's own status maps to heat and acts as a *floor* (see calcHeat);
86
+ // `done` at any level drops that node and its whole subtree, unless `showDone` is on
87
+ // (then it renders dimmed, so `done` is reversible from the board, not a trapdoor).
88
+ // Stable ids are preserved so callback paths address the real underlying nodes.
89
+ const num = (v, d) => { const x = Number(v); return Number.isFinite(x) && x > 0 ? x : d; };
90
+ function clone(n) {
91
+ const isDone = n.status === 'done';
92
+ if (isDone && !showDone) return null; // done node (any level) drops its subtree
93
+ const ownHeat = STATUS_HEAT[n.status] ?? 0; // done -> 0 (calm); shown dimmed via _done
94
+ const kids = n.children;
95
+ if (kids && kids.length) {
96
+ const cc = [];
97
+ for (const k of kids) { const c = clone(k); if (c) cc.push(c); }
98
+ if (cc.length) return { id: n.id, name: n.name, weight: num(n.weight, 1),
99
+ status: n.status, heat: ownHeat, _done: isDone, children: cc };
100
+ // every child was dropped (all done, hidden): keep this node as a single tile if it
101
+ // carries its own status, else the empty container is effectively done — drop it.
102
+ if (n.status && !isDone) return { id: n.id, name: n.name, weight: num(n.weight, 1),
103
+ status: n.status, heat: ownHeat, children: null };
104
+ return null;
105
+ }
106
+ return { id: n.id, name: n.name, weight: num(n.weight, 1),
107
+ status: n.status || 'todo', heat: ownHeat, _done: isDone, children: null };
108
+ }
109
+ function buildWorking(src) {
110
+ const top = [];
111
+ for (const k of (src.children || [])) { const c = clone(k); if (c) top.push(c); }
112
+ return { id: src.id, name: src.name || 'Priorities', weight: num(src.weight, 1), children: top };
113
+ }
114
+ // resolve a dotted-id path back to the node in the SOURCE tree (so local edits mirror
115
+ // into srcState and survive a rebuild) — mirrors server.mjs resolvePath.
116
+ function resolveInSrc(path) {
117
+ let node = srcState;
118
+ for (const part of path.split('.')) { const k = (node.children || []).find(c => c.id === part); if (!k) return null; node = k; }
119
+ return node;
120
+ }
121
+ function rebuild() {
122
+ root = buildWorking(srcState);
123
+ viewRoot = (viewRootId && find(root, viewRootId)) || root;
124
+ viewRootId = viewRoot === root ? null : viewRoot.id;
125
+ if (selId && !find(root, selId)) selId = null;
126
+ }
127
+
128
+ // ---- squarified treemap (Bruls/Huizing/van Wijk) — proven, unchanged ----
129
+ function worstAspect(row, rowArea, side) {
130
+ const thick = rowArea / side; let worst = 1;
131
+ for (const it of row) { const len = (it.area / rowArea) * side; const r = Math.max(thick / len, len / thick); if (r > worst) worst = r; }
132
+ return worst;
133
+ }
134
+ function squarify(items, x, y, w, h, out) {
135
+ const rest = items.slice();
136
+ while (rest.length) {
137
+ const side = Math.min(w, h); let row = [], rowArea = 0, worst = Infinity;
138
+ while (rest.length) {
139
+ const it = rest[0], na = rowArea + it.area, nw = worstAspect(row.concat(it), na, side);
140
+ if (row.length === 0 || nw <= worst) { row.push(rest.shift()); rowArea = na; worst = nw; } else break;
141
+ }
142
+ let thick = rowArea / side;
143
+ if (w >= h) { thick = Math.min(thick, w); let cy = y;
144
+ for (const it of row) { const ih = (it.area / rowArea) * h; out.push({ node: it.node, x, y: cy, w: thick, h: ih }); cy += ih; }
145
+ x += thick; w -= thick;
146
+ } else { thick = Math.min(thick, h); let cx = x;
147
+ for (const it of row) { const iw = (it.area / rowArea) * w; out.push({ node: it.node, x: cx, y, w: iw, h: thick }); cx += iw; }
148
+ y += thick; h -= thick;
149
+ }
150
+ }
151
+ }
152
+ function layout(node, x, y, w, h, depth, isView) {
153
+ node._rect = { x, y, w, h, depth };
154
+ const ch = node.children; if (!ch || !ch.length) return;
155
+ const header = isView ? 0 : HEADER;
156
+ const ix = x + PAD, iy = y + PAD + header, iw = w - 2 * PAD, ih = h - 2 * PAD - header;
157
+ if (iw < 4 || ih < 4) { ch.forEach(c => { c._parent = node; layout(c, x, y, 0, 0, depth + 1, false); }); return; }
158
+ const total = ch.reduce((s, c) => s + Math.max(c.weight, 1e-6), 0), area = iw * ih;
159
+ const items = ch.map(c => ({ node: c, area: area * (Math.max(c.weight, 1e-6) / total) }));
160
+ if (freeze) items.sort((a, b) => (a.node._ord == null ? 0 : a.node._ord) - (b.node._ord == null ? 0 : b.node._ord));
161
+ else items.sort((a, b) => b.area - a.area || String(a.node.id).localeCompare(String(b.node.id)));
162
+ items.forEach((it, i) => { it.node._ord = i; });
163
+ const out = []; squarify(items, ix, iy, iw, ih, out);
164
+ for (const o of out) { o.node._parent = node; layout(o.node, o.x, o.y, o.w, o.h, depth + 1, false); }
165
+ }
166
+ function calcHeat(node) {
167
+ const own = node.heat || 0; // this node's own status-heat (0 if none)
168
+ const ch = node.children;
169
+ if (!ch || !ch.length) { node._heat = own; return node._heat; }
170
+ let a = 0, s = 0;
171
+ for (const c of ch) { const ca = Math.max(c._rect.w * c._rect.h, 1e-4), hc = calcHeat(c); a += ca; s += hc * ca; }
172
+ const rollup = a ? s / a : 0;
173
+ node._heat = Math.max(own, rollup); // own status is a floor; children can only push hotter
174
+ return node._heat;
175
+ }
176
+
177
+ // ---- colour ----
178
+ const lerp = (a, b, t) => a + (b - a) * t;
179
+ function heatColor(h) {
180
+ h = Math.max(0, Math.min(1, h));
181
+ const st = [[0, [50, 48, 41]], [0.35, [92, 74, 46]], [0.7, [176, 106, 31]], [1, [216, 67, 46]]];
182
+ for (let i = 0; i < st.length - 1; i++) {
183
+ const [p0, c0] = st[i], [p1, c1] = st[i + 1];
184
+ if (h <= p1) { const t = (h - p0) / (p1 - p0); return `rgb(${Math.round(lerp(c0[0], c1[0], t))},${Math.round(lerp(c0[1], c1[1], t))},${Math.round(lerp(c0[2], c1[2], t))})`; }
185
+ }
186
+ return 'rgb(216,67,46)';
187
+ }
188
+ const textColor = h => h > 0.6 ? '#2A1206' : 'var(--tlm-ink,#ECE7DA)';
189
+ const esc = s => String(s).replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
190
+
191
+ // ---- tree helpers (stable string ids) ----
192
+ function find(n, id) { if (n.id === id) return n; for (const c of (n.children || [])) { const f = find(c, id); if (f) return f; } return null; }
193
+ const selected = () => selId ? find(root, selId) : null;
194
+ const activeContainer = () => { const s = selected(); return (s && s.children && s.children.length) ? s : viewRoot; };
195
+ function resizeTarget(node, ac) { let a = node; while (a && a._parent && a._parent !== ac) a = a._parent; return (a && a._parent === ac) ? a : null; }
196
+ function pathNodes(n) { const p = []; let c = n; while (c) { p.unshift(c); c = c._parent; } return p; }
197
+ function buildPath(n) { const p = []; let c = n; while (c && c._parent) { p.unshift(c.id); c = c._parent; } return p.join('.'); }
198
+ function shareOf(node) { const p = node._parent; if (!p) return 100; const s = p.children.reduce((a, c) => a + c.weight, 0); return s ? node.weight / s * 100 : 100; }
199
+ function setShare(node, pct) {
200
+ const p = node._parent; if (!p) return;
201
+ const others = p.children.reduce((s, c) => s + c.weight, 0) - node.weight; if (others <= 1e-9) return;
202
+ const f = Math.max(0.02, Math.min(0.97, pct / 100)); node.weight = f / (1 - f) * others;
203
+ }
204
+
205
+ // ---- board render ----
206
+ function renderBoard() {
207
+ const W = boardEl.clientWidth, H = boardEl.clientHeight;
208
+ layout(viewRoot, 0, 0, W, H, 0, true); calcHeat(viewRoot);
209
+ const vis = []; (function walk(n) { (n.children || []).forEach(c => { vis.push(c); walk(c); }); })(viewRoot);
210
+ const seen = new Set();
211
+ for (const node of vis) {
212
+ const r = node._rect;
213
+ if (r.w < 3 || r.h < 3) { if (tileEls[node.id]) { tileEls[node.id].remove(); delete tileEls[node.id]; } continue; }
214
+ seen.add(node.id);
215
+ const isParent = node.children && node.children.length;
216
+ let el = tileEls[node.id];
217
+ if (!el) {
218
+ el = boardEl.ownerDocument.createElement('div'); el.className = 'tile'; el.style.opacity = '0';
219
+ el.dataset.id = node.id; attach(el); tileEls[node.id] = el; boardEl.appendChild(el);
220
+ requestAnimationFrame(() => { if (tileEls[node.id]) el.style.opacity = '1'; });
221
+ }
222
+ el.style.left = (r.x + INSET) + 'px'; el.style.top = (r.y + INSET) + 'px';
223
+ el.style.width = Math.max(0, r.w - 2 * INSET) + 'px'; el.style.height = Math.max(0, r.h - 2 * INSET) + 'px';
224
+ el.style.zIndex = r.depth;
225
+ el.style.background = node._done ? DONE_COLOR : heatColor(node._heat);
226
+ el.style.color = node._done ? 'var(--tlm-ink,#ECE7DA)' : textColor(node._heat);
227
+ const isSel = selId === node.id, isPar = node.children && node.children.length;
228
+ el.classList.toggle('sel', isSel && !isPar); el.classList.toggle('armed', isSel && !!isPar);
229
+ el.classList.toggle('hot', node._heat > 0.66 && !node._done);
230
+ el.classList.toggle('done', !!node._done);
231
+ const wt = showWeights ? `<span class="wt">${node.weight.toFixed(node.weight < 10 ? 1 : 0)}</span>` : '';
232
+ let html = '';
233
+ if (isParent) { if (r.w > 54 && r.h > 30) html = `<div class="hd"><span class="nm">${esc(node.name)}</span>${wt}</div>`; }
234
+ else { if (r.w > 40 && r.h > 26) html = `<div class="leaf"><span class="nm">${esc(node.name)}</span>${wt}</div>`; }
235
+ if (el._html !== html) { el.innerHTML = html; el._html = html; }
236
+ }
237
+ for (const id in tileEls) { if (!seen.has(id)) { tileEls[id].remove(); delete tileEls[id]; } }
238
+ }
239
+
240
+ function attach(el) {
241
+ el.addEventListener('pointerdown', onDown);
242
+ el.addEventListener('dblclick', e => {
243
+ e.stopPropagation();
244
+ const n = find(root, el.dataset.id);
245
+ if (n && n.children && n.children.length) { viewRoot = n; viewRootId = n.id; selId = null; render(); }
246
+ });
247
+ }
248
+ function onDown(e) {
249
+ e.stopPropagation();
250
+ const id = e.currentTarget.dataset.id, node = find(root, id); if (!node) return;
251
+ let ac = activeContainer(), target = resizeTarget(node, ac);
252
+ if (!target) { ac = viewRoot; target = resizeTarget(node, ac) || node; } // dragged outside armed parent -> top level
253
+ const p = target._parent, others = p ? p.children.reduce((s, c) => s + c.weight, 0) - target.weight : 0;
254
+ drag = { tappedId: id, node: target, p, others, sx: e.clientX, sy: e.clientY, sw: target.weight, moved: false };
255
+ freeze = true; boardEl.classList.add('nodrag');
256
+ try { e.currentTarget.setPointerCapture(e.pointerId); } catch (_) { }
257
+ window.addEventListener('pointermove', onMove);
258
+ window.addEventListener('pointerup', onUp);
259
+ }
260
+ function onMove(e) {
261
+ if (!drag) return;
262
+ const dx = e.clientX - drag.sx, dy = e.clientY - drag.sy;
263
+ if (!drag.moved && Math.hypot(dx, dy) < 4) return;
264
+ drag.moved = true;
265
+ if (drag.p && drag.others > 0) {
266
+ const delta = dx + dy; // right and down grow
267
+ let w = drag.sw * Math.exp(delta / 220);
268
+ const minW = drag.others * 0.01 / 0.99, maxW = drag.others * 0.97 / 0.03;
269
+ drag.node.weight = Math.max(minW, Math.min(maxW, w));
270
+ renderBoard();
271
+ }
272
+ }
273
+ function onUp() {
274
+ window.removeEventListener('pointermove', onMove);
275
+ window.removeEventListener('pointerup', onUp);
276
+ boardEl.classList.remove('nodrag'); freeze = false;
277
+ if (drag && !drag.moved) { selId = drag.tappedId; }
278
+ else if (drag && drag.moved && drag.node) {
279
+ const p = buildPath(drag.node); onWeightChange(p, drag.node.weight);
280
+ const sn = resolveInSrc(p); if (sn) sn.weight = drag.node.weight; // mirror into source
281
+ }
282
+ drag = null; render();
283
+ }
284
+
285
+ function setStatus(node, status) {
286
+ const p = buildPath(node);
287
+ onStatusChange(p, status); // persist (host turns this into a write)
288
+ const sn = resolveInSrc(p); if (sn) sn.status = status;
289
+ rebuild(); render(); // rebuild from source so done-drop / show-done stay consistent
290
+ }
291
+
292
+ // ---- controls (breadcrumb · status buttons for a selected leaf · weight toggle · size slider) ----
293
+ function clearSizebar() { if (!controlsEl) return; const o = controlsEl.querySelector('.tlm-sizebar'); if (o) o.remove(); }
294
+ function renderControls() {
295
+ if (!controlsEl) return;
296
+ const sel = selected(), bc = pathNodes(viewRoot);
297
+ const crumb = bc.map((n, i) => i === bc.length - 1 ? `<b>${esc(n.name)}</b>`
298
+ : `<span class="c" data-id="${esc(n.id)}">${esc(n.name)}</span><span class="sep">/</span>`).join(' ');
299
+ let html = `<div class="crumb">${crumb}</div>`;
300
+ if (sel) html += `<div class="grp"><span class="lbl">status</span>` + // any item, at any level, can hold a status
301
+ STATUSES.map(s => `<button class="st${sel.status === s ? ' on' : ''}" data-s="${s}">${s.replace('_', ' ')}</button>`).join('') + `</div>`;
302
+ html += `<div class="grp"><button id="wtTog" class="tog">weights: ${showWeights ? 'on' : 'off'}</button>` +
303
+ `<button id="doneTog" class="tog">done: ${showDone ? 'shown' : 'hidden'}</button></div>`;
304
+ controlsEl.innerHTML = html;
305
+ clearSizebar();
306
+ if (sel && sel._parent) {
307
+ const only = sel._parent.children.length === 1;
308
+ const sb = controlsEl.ownerDocument.createElement('div'); sb.className = 'tlm-sizebar';
309
+ sb.innerHTML = `fine-tune <b>${esc(sel.name)}</b> in <b>${esc(sel._parent.name)}</b>
310
+ <input type="range" id="sizeR" min="2" max="95" value="${Math.round(shareOf(sel))}" ${only ? 'disabled' : ''}>
311
+ <span class="pct" id="sizeP">${Math.round(shareOf(sel))}%</span>`;
312
+ controlsEl.appendChild(sb);
313
+ const rg = sb.querySelector('#sizeR'), lab = sb.querySelector('#sizeP');
314
+ rg.addEventListener('input', () => { setShare(sel, +rg.value); lab.textContent = Math.round(shareOf(sel)) + '%'; renderBoard(); });
315
+ rg.addEventListener('change', () => { const p = buildPath(sel); onWeightChange(p, sel.weight); const sn = resolveInSrc(p); if (sn) sn.weight = sel.weight; });
316
+ }
317
+ controlsEl.querySelectorAll('.c').forEach(s => s.onclick = () => { const t = find(root, s.dataset.id); if (t) { viewRoot = t; viewRootId = t.id; selId = null; render(); } });
318
+ controlsEl.querySelector('#wtTog').onclick = () => { showWeights = !showWeights; render(); };
319
+ controlsEl.querySelector('#doneTog').onclick = () => { showDone = !showDone; rebuild(); render(); };
320
+ if (sel) controlsEl.querySelectorAll('.st').forEach(b => b.onclick = () => setStatus(sel, b.dataset.s));
321
+ }
322
+
323
+ function render() { renderControls(); renderBoard(); }
324
+
325
+ // ---- background click clears selection; observe resize ----
326
+ const onBgDown = e => { if (e.target === boardEl) { selId = null; render(); } };
327
+ boardEl.addEventListener('pointerdown', onBgDown);
328
+ const ro = new ResizeObserver(() => renderBoard()); ro.observe(boardEl);
329
+
330
+ rebuild(); render();
331
+
332
+ return {
333
+ update(newState) {
334
+ if (drag) return; // never yank the board out from under a drag
335
+ srcState = newState; rebuild(); render();
336
+ },
337
+ setShowDone(v) { showDone = !!v; rebuild(); render(); },
338
+ getState() { return srcState; },
339
+ destroy() {
340
+ ro.disconnect();
341
+ boardEl.removeEventListener('pointerdown', onBgDown);
342
+ for (const id in tileEls) { tileEls[id].remove(); delete tileEls[id]; }
343
+ if (controlsEl) controlsEl.innerHTML = '';
344
+ },
345
+ };
346
+ }
package/dashboard.html ADDED
@@ -0,0 +1,67 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Tilemon — priority board</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
10
+ <style>
11
+ :root{--tlm-bg:#14120D;--tlm-ink:#ECE7DA;--tlm-dim:#9A9182;--tlm-line:#2A2820;--tlm-gold:#E8C56A;--tlm-panel:#1C1A14}
12
+ *{box-sizing:border-box}
13
+ html,body{margin:0;height:100%;background:var(--tlm-bg);color:var(--tlm-ink);
14
+ font-family:"Space Grotesk",system-ui,sans-serif;-webkit-font-smoothing:antialiased}
15
+ #app{display:flex;flex-direction:column;height:100vh;padding:14px;gap:10px}
16
+ #board{flex:1;min-height:340px;border-radius:10px}
17
+ .hint{font-family:"Space Mono",monospace;font-size:10.5px;color:var(--tlm-dim);line-height:1.5}
18
+ .hint .off{color:#b0563a}
19
+ </style>
20
+ </head>
21
+ <body>
22
+ <div id="app">
23
+ <div id="controls"></div>
24
+ <div id="board"></div>
25
+ <div class="hint" id="hint">drag = resize the item it belongs to (down/right bigger, up/left smaller) · click an item to arm it (dashed), then drags resize the items inside · double-click = drill in · click any tile to set its status</div>
26
+ </div>
27
+
28
+ <script type="module">
29
+ // dashboard.html — the FILE source adapter. It owns the "where does data go" answer:
30
+ // reads from /api/state, writes back via POST, live-refreshes over SSE. The renderer
31
+ // (board.js) knows none of this — swap this file for a DB-backed host and the renderer
32
+ // is unchanged. See SPEC §3 (renderer over sources) and §4.5 (this wiring).
33
+ import { mount } from './board.js';
34
+
35
+ const boardEl = document.getElementById('board');
36
+ const controlsEl = document.getElementById('controls');
37
+ const hint = document.getElementById('hint');
38
+
39
+ async function getState() {
40
+ const r = await fetch('/api/state');
41
+ if (!r.ok) throw new Error('GET /api/state -> ' + r.status);
42
+ return r.json();
43
+ }
44
+ async function post(path, body) {
45
+ try {
46
+ const r = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
47
+ if (!r.ok) console.error('POST', path, '->', r.status, await r.text());
48
+ } catch (e) { console.error('POST', path, 'failed', e); }
49
+ }
50
+
51
+ const state = await getState();
52
+ const board = mount(boardEl, controlsEl, {
53
+ state,
54
+ onWeightChange: (path, weight) => post('/api/weight', { path, weight }), // human resized -> persist
55
+ onStatusChange: (path, status) => post('/api/status', { path, status }), // status flagged -> persist
56
+ });
57
+
58
+ // Live refresh: any write (ours, another tab's, an agent's POST, or a direct file edit
59
+ // the server is watching) emits a `change` event. Re-fetch and update; board.js preserves
60
+ // the current drill level and selection, and ignores updates mid-drag.
61
+ const es = new EventSource('/api/events');
62
+ es.onmessage = async () => { try { board.update(await getState()); } catch (e) { console.error(e); } };
63
+ es.onerror = () => { hint.innerHTML = '<span class="off">live updates disconnected — reconnecting…</span>'; };
64
+ es.onopen = () => { hint.textContent = 'drag = resize the item it belongs to · click an item to arm it, then drags resize the items inside · double-click = drill in · click any tile to set its status · "done" toggle brings finished items back'; };
65
+ </script>
66
+ </body>
67
+ </html>
package/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "tilemon",
3
+ "version": "0.0.1",
4
+ "description": "A priority board where importance is space. Zero-sum treemap over a JSON file; agents flag status, you own the weights.",
5
+ "type": "module",
6
+ "bin": { "tilemon": "server.mjs" },
7
+ "scripts": {
8
+ "start": "node server.mjs ./state.json",
9
+ "test": "node test/board.test.mjs"
10
+ },
11
+ "engines": { "node": ">=18" },
12
+ "files": ["server.mjs", "dashboard.html", "board.js"],
13
+ "license": "MIT"
14
+ }
package/server.mjs ADDED
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env node
2
+ // Tilemon v1 — local file-source server (scaffold; verify in Claude Code).
3
+ // Zero external dependencies — Node 18+ built-ins only, so `npx`/`node` runs with no install.
4
+ //
5
+ // node server.mjs ./state.json # or: npx tilemon ./state.json
6
+ // PORT=4000 TILEMON_TOKEN=secret node server.mjs ./state.json
7
+ //
8
+ // Routes:
9
+ // GET / -> dashboard.html
10
+ // GET /api/state -> current tree (JSON)
11
+ // GET /api/events -> Server-Sent Events; emits "change" on any update
12
+ // POST /api/status -> { "path": "work.ship-v2.data-migration", "status": "blocked" } (AGENTS)
13
+ // POST /api/weight -> { "path": "work", "weight": 3.5 } (HUMAN / UI)
14
+ //
15
+ // Auth model (capability-scoped, the locked design):
16
+ // - /api/status is the AGENT write surface. It can ONLY change a node's status (any level).
17
+ // - /api/weight is the HUMAN write surface. Weights are yours; agents never touch them.
18
+ // - If TILEMON_TOKEN is set, BOTH write routes require `Authorization: Bearer <token>`.
19
+ // Leave it unset on a trusted LAN; set it before exposing the port publicly.
20
+
21
+ import http from 'node:http';
22
+ import { readFile, writeFile, rename, watch } from 'node:fs/promises';
23
+ import { existsSync } from 'node:fs';
24
+ import { fileURLToPath } from 'node:url';
25
+ import { dirname, join } from 'node:path';
26
+
27
+ const __dir = dirname(fileURLToPath(import.meta.url));
28
+ const FILE = process.argv[2] || './state.json';
29
+ const PORT = Number(process.env.PORT) || 4000;
30
+ const TOKEN = process.env.TILEMON_TOKEN || null;
31
+ const DASH = join(__dir, 'dashboard.html'); // rename dashboard-reference.html -> dashboard.html, or point here
32
+
33
+ const VALID_STATUS = new Set(['todo', 'in_progress', 'blocked', 'done']);
34
+ const clients = new Set();
35
+
36
+ // ---- state io (atomic write to avoid half-written reads) ----
37
+ async function readState() {
38
+ return JSON.parse(await readFile(FILE, 'utf8'));
39
+ }
40
+ let writing = Promise.resolve();
41
+ async function writeState(state) {
42
+ writing = writing.then(async () => {
43
+ const tmp = FILE + '.tmp';
44
+ await writeFile(tmp, JSON.stringify(state, null, 2));
45
+ await rename(tmp, FILE); // atomic on same filesystem
46
+ });
47
+ return writing;
48
+ }
49
+
50
+ // ---- path resolution: dotted stable ids, e.g. "work.ship-v2.data-migration" ----
51
+ function resolvePath(state, path) {
52
+ const parts = path.split('.');
53
+ let node = state, depth = 0;
54
+ // root is unnamed container; first part matches a top-level child
55
+ for (const part of parts) {
56
+ const kids = node.children || [];
57
+ const next = kids.find(c => c.id === part);
58
+ if (!next) return null;
59
+ node = next; depth++;
60
+ }
61
+ return depth === parts.length ? node : null;
62
+ }
63
+
64
+ // ---- SSE broadcast ----
65
+ function broadcast() {
66
+ for (const res of clients) res.write('data: change\n\n');
67
+ }
68
+
69
+ // ---- file watcher: external edits (agents writing the file directly, manual edits) also push ----
70
+ let debounce;
71
+ (async () => {
72
+ try {
73
+ const watcher = watch(FILE);
74
+ for await (const _ of watcher) {
75
+ clearTimeout(debounce);
76
+ debounce = setTimeout(broadcast, 80);
77
+ }
78
+ } catch (_) { /* file may not exist yet */ }
79
+ })();
80
+
81
+ function authed(req) {
82
+ if (!TOKEN) return true;
83
+ return req.headers['authorization'] === `Bearer ${TOKEN}`;
84
+ }
85
+ function body(req) {
86
+ return new Promise((res, rej) => {
87
+ let b = ''; req.on('data', c => b += c);
88
+ req.on('end', () => { try { res(b ? JSON.parse(b) : {}); } catch (e) { rej(e); } });
89
+ });
90
+ }
91
+ function json(res, code, obj) {
92
+ res.writeHead(code, { 'content-type': 'application/json' });
93
+ res.end(JSON.stringify(obj));
94
+ }
95
+
96
+ const server = http.createServer(async (req, res) => {
97
+ const url = new URL(req.url, `http://localhost:${PORT}`);
98
+
99
+ if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
100
+ try { res.writeHead(200, { 'content-type': 'text/html' }); res.end(await readFile(DASH)); }
101
+ catch { json(res, 500, { error: 'dashboard.html not found next to server.mjs' }); }
102
+ return;
103
+ }
104
+
105
+ if (req.method === 'GET' && url.pathname === '/board.js') {
106
+ try { res.writeHead(200, { 'content-type': 'text/javascript' }); res.end(await readFile(join(__dir, 'board.js'))); }
107
+ catch { json(res, 500, { error: 'board.js not found next to server.mjs' }); }
108
+ return;
109
+ }
110
+
111
+ if (req.method === 'GET' && url.pathname === '/api/state') {
112
+ try { json(res, 200, await readState()); }
113
+ catch (e) { json(res, 500, { error: String(e) }); }
114
+ return;
115
+ }
116
+
117
+ if (req.method === 'GET' && url.pathname === '/api/events') {
118
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
119
+ res.write(': connected\n\n');
120
+ clients.add(res);
121
+ const ping = setInterval(() => res.write(': ping\n\n'), 25000);
122
+ req.on('close', () => { clearInterval(ping); clients.delete(res); });
123
+ return;
124
+ }
125
+
126
+ if (req.method === 'POST' && url.pathname === '/api/status') { // AGENT write
127
+ if (!authed(req)) return json(res, 401, { error: 'unauthorized' });
128
+ try {
129
+ const { path, status } = await body(req);
130
+ if (!VALID_STATUS.has(status)) return json(res, 400, { error: 'bad status' });
131
+ const state = await readState();
132
+ const node = resolvePath(state, path);
133
+ if (!node) return json(res, 404, { error: 'path not found: ' + path });
134
+ node.status = status; // status only — never weight; valid on any node
135
+ await writeState(state); broadcast();
136
+ json(res, 200, { ok: true });
137
+ } catch (e) { json(res, 400, { error: String(e) }); }
138
+ return;
139
+ }
140
+
141
+ if (req.method === 'POST' && url.pathname === '/api/weight') { // HUMAN / UI write
142
+ if (!authed(req)) return json(res, 401, { error: 'unauthorized' });
143
+ try {
144
+ const { path, weight } = await body(req);
145
+ const state = await readState();
146
+ const node = resolvePath(state, path);
147
+ if (!node) return json(res, 404, { error: 'path not found: ' + path });
148
+ node.weight = Number(weight); // weight only
149
+ await writeState(state); broadcast();
150
+ json(res, 200, { ok: true });
151
+ } catch (e) { json(res, 400, { error: String(e) }); }
152
+ return;
153
+ }
154
+
155
+ json(res, 404, { error: 'not found' });
156
+ });
157
+
158
+ if (!existsSync(FILE)) console.warn(`! ${FILE} does not exist yet — create it or copy state.sample.json`);
159
+ server.listen(PORT, () => {
160
+ console.log(`Tilemon serving ${FILE}`);
161
+ console.log(` dashboard : http://localhost:${PORT}`);
162
+ console.log(` state : http://localhost:${PORT}/api/state`);
163
+ console.log(TOKEN ? ' auth : token required on writes' : ' auth : OPEN (set TILEMON_TOKEN before exposing the port)');
164
+ });