@nynb/sandpaper 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.
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/bin/brain-inject.js +23 -0
- package/bin/brain-stamp-check.js +36 -0
- package/bin/cli.js +62 -0
- package/brain/README.md +99 -0
- package/brain/assets/brain.css +472 -0
- package/brain/assets/brain.js +189 -0
- package/brain/assets/theme.css +88 -0
- package/package.json +19 -0
- package/public/sp-markdown.js +172 -0
- package/public/toolbar.css +220 -0
- package/public/toolbar.js +564 -0
- package/skill/sandpaper/SKILL.md +114 -0
- package/skill/sandpaper/commands/canvas.md +31 -0
- package/skill/sandpaper/commands/decide.md +16 -0
- package/skill/sandpaper/commands/help.md +25 -0
- package/skill/sandpaper/commands/init.md +113 -0
- package/skill/sandpaper/commands/learn.md +11 -0
- package/skill/sandpaper/commands/log.md +9 -0
- package/skill/sandpaper/commands/open.md +15 -0
- package/skill/sandpaper/commands/plan.md +16 -0
- package/skill/sandpaper/commands/serve.md +8 -0
- package/skill/sandpaper/commands/stamp.md +25 -0
- package/skill/sandpaper/commands/sync.md +17 -0
- package/skill/sandpaper/commands/theme.md +12 -0
- package/src/claude.js +226 -0
- package/src/edit.js +113 -0
- package/src/server.js +327 -0
- package/src/setup.js +564 -0
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
// toolbar.js — Sandpaper conversation surface (injected into the served document).
|
|
2
|
+
// Holds the back-and-forth with Claude on the page: streams replies, shows what each
|
|
3
|
+
// turn changed (and undo), survives the live-reload, and keeps the document the star.
|
|
4
|
+
import { renderMarkdown } from '/__sandpaper/sp-markdown.js';
|
|
5
|
+
|
|
6
|
+
(function () {
|
|
7
|
+
'use strict';
|
|
8
|
+
var API = '/__sandpaper';
|
|
9
|
+
var COLORS = {
|
|
10
|
+
init: '#8A8578', thinking: '#C75B39', editing: '#3F5247', tool_using: '#3F5247',
|
|
11
|
+
waiting: '#C98A1E', error: '#B23A2E', done: '#4E7C59', idle: '#8A8578',
|
|
12
|
+
};
|
|
13
|
+
var BUSY = ['init', 'thinking', 'editing', 'tool_using', 'waiting'];
|
|
14
|
+
var SKEY = 'sp-thread:' + location.pathname; // transcript persists per-document
|
|
15
|
+
|
|
16
|
+
// crisp stroke icons (centre perfectly via viewBox; inherit the button's currentColor) —
|
|
17
|
+
// the ⌖/✎ glyphs sat off-centre and read muddy at 36px.
|
|
18
|
+
var ICON_PICK = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">' +
|
|
19
|
+
'<circle cx="12" cy="12" r="4.25"/><line x1="12" y1="2.5" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="21.5"/>' +
|
|
20
|
+
'<line x1="2.5" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="21.5" y2="12"/></svg>';
|
|
21
|
+
var ICON_EDIT = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
22
|
+
'<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>';
|
|
23
|
+
|
|
24
|
+
// ---------- build the panel (static template — no untrusted data) ----------
|
|
25
|
+
var panel = document.createElement('div');
|
|
26
|
+
panel.id = 'sp-panel';
|
|
27
|
+
panel.className = 'sp-collapsed';
|
|
28
|
+
panel.innerHTML =
|
|
29
|
+
'<div id="sp-head">' +
|
|
30
|
+
'<span id="sp-chip"><span id="sp-led"></span><span id="sp-who">Claude Code</span><span id="sp-label">idle</span></span>' +
|
|
31
|
+
'<span id="sp-cost"></span>' +
|
|
32
|
+
'<button type="button" id="sp-undo" hidden title="Undo the last direct edit">⟲ undo</button>' +
|
|
33
|
+
'<button type="button" id="sp-min" title="Minimize">–</button>' +
|
|
34
|
+
'<button type="button" id="sp-toggle" aria-label="Expand or collapse">▸</button>' +
|
|
35
|
+
'</div>' +
|
|
36
|
+
'<div id="sp-thread" hidden></div>' +
|
|
37
|
+
'<div id="sp-target" hidden></div>' +
|
|
38
|
+
'<form id="sp-form">' +
|
|
39
|
+
'<input id="sp-input" placeholder="Ask, discuss, or describe a change…" autocomplete="off" />' +
|
|
40
|
+
'<div id="sp-actions">' +
|
|
41
|
+
'<button type="button" id="sp-pick" title="Scope — point at an element to target your message">' + ICON_PICK + '</button>' +
|
|
42
|
+
'<button type="button" id="sp-edit" title="Edit text in place — your words, no AI">' + ICON_EDIT + '</button>' +
|
|
43
|
+
'<span class="sp-spring"></span>' +
|
|
44
|
+
'<button type="button" id="sp-sling" title="Send to terminal — copy a ready instruction to paste into your Claude session">>_</button>' +
|
|
45
|
+
'<button type="submit" id="sp-send">Sand</button>' +
|
|
46
|
+
'</div>' +
|
|
47
|
+
'</form>';
|
|
48
|
+
document.body.appendChild(panel);
|
|
49
|
+
|
|
50
|
+
var chip = panel.querySelector('#sp-chip'), led = panel.querySelector('#sp-led'),
|
|
51
|
+
label = panel.querySelector('#sp-label'), cost = panel.querySelector('#sp-cost'),
|
|
52
|
+
thread = panel.querySelector('#sp-thread'), input = panel.querySelector('#sp-input'),
|
|
53
|
+
sendBtn = panel.querySelector('#sp-send'), pickBtn = panel.querySelector('#sp-pick'),
|
|
54
|
+
editBtn = panel.querySelector('#sp-edit'), undoBtn = panel.querySelector('#sp-undo'),
|
|
55
|
+
targetTag = panel.querySelector('#sp-target'), toggleBtn = panel.querySelector('#sp-toggle');
|
|
56
|
+
|
|
57
|
+
// ---------- adopt the host's skin when it has one ----------
|
|
58
|
+
// The toolbar ships a hardcoded --sp-* palette so it stands alone on ANY page. But when it is
|
|
59
|
+
// injected into a themed Sandpaper surface (the brain), the host defines theme.css tokens on :root —
|
|
60
|
+
// read them and override our defaults so a re-skin reaches the toolbar too. No host theme → no-op.
|
|
61
|
+
// Re-runs on every live-reload, so a /sandpaper:theme change propagates here on the next reload.
|
|
62
|
+
var SKIN_MAP = { '--sp-paper': '--paper', '--sp-paper-2': '--panel', '--sp-ink': '--ink',
|
|
63
|
+
'--sp-clay': '--clay', '--sp-pine': '--pine', '--sp-moss': '--moss', '--sp-rust': '--rust',
|
|
64
|
+
'--sp-mute': '--mute', '--sp-text-quote': '--text-quote', '--sp-plate': '--plate' };
|
|
65
|
+
// The chrome pairs LIGHT text on --sp-ink/--sp-plate and DARK text on --sp-paper. Adopting a host
|
|
66
|
+
// theme that breaks that polarity (e.g. a light --ink used only as body text, never as a fill) makes
|
|
67
|
+
// the dark head wash out — so adopt the neutral SURFACES only when the host keeps the expected
|
|
68
|
+
// light-paper / dark-ink contrast; the accent hues are always safe to take.
|
|
69
|
+
var SKIN_NEUTRALS = ['--sp-paper', '--sp-paper-2', '--sp-ink', '--sp-plate', '--sp-text-quote'];
|
|
70
|
+
function parseColor(c) {
|
|
71
|
+
if (!c) return null; c = c.trim();
|
|
72
|
+
if (c.charAt(0) === '#') { var h = c.slice(1);
|
|
73
|
+
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
|
|
74
|
+
if (h.length < 6) return null;
|
|
75
|
+
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; }
|
|
76
|
+
var m = c.match(/rgba?\(([^)]+)\)/); if (!m) return null;
|
|
77
|
+
var p = m[1].split(',').map(parseFloat); return [p[0], p[1], p[2]];
|
|
78
|
+
}
|
|
79
|
+
function luminance(c) { // WCAG relative luminance, 0 (black) … 1 (white)
|
|
80
|
+
var rgb = parseColor(c); if (!rgb) return null;
|
|
81
|
+
var a = rgb.map(function (v) { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); });
|
|
82
|
+
return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2];
|
|
83
|
+
}
|
|
84
|
+
function adoptHostSkin() {
|
|
85
|
+
var cs = getComputedStyle(document.documentElement), read = {}, found = false;
|
|
86
|
+
for (var k in SKIN_MAP) { var v = cs.getPropertyValue(SKIN_MAP[k]).trim(); if (v) { read[k] = v; found = true; } }
|
|
87
|
+
if (!found) return null; // arbitrary host page, no Sandpaper theme — keep our shipped defaults
|
|
88
|
+
var lp = luminance(read['--sp-paper']), li = luminance(read['--sp-ink']);
|
|
89
|
+
var polarityOK = lp != null && li != null && lp > 0.55 && li < 0.4 && (lp - li) > 0.4;
|
|
90
|
+
var vals = {};
|
|
91
|
+
for (var k2 in read) {
|
|
92
|
+
if (!polarityOK && SKIN_NEUTRALS.indexOf(k2) >= 0) continue; // skip risky surface swaps; keep our legible defaults
|
|
93
|
+
vals[k2] = read[k2];
|
|
94
|
+
}
|
|
95
|
+
for (var k3 in vals) panel.style.setProperty(k3, vals[k3]);
|
|
96
|
+
return Object.keys(vals).length ? vals : null;
|
|
97
|
+
}
|
|
98
|
+
var hostSkin = adoptHostSkin();
|
|
99
|
+
|
|
100
|
+
var turns = Object.create(null); // turnId -> live turn record
|
|
101
|
+
var pendingTurn = null; // optimistic user turn awaiting its server turnId
|
|
102
|
+
var sel = null, picking = false;
|
|
103
|
+
var lastChangedCids = [];
|
|
104
|
+
var stickBottom = true;
|
|
105
|
+
|
|
106
|
+
// ---------- small helpers ----------
|
|
107
|
+
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
|
|
108
|
+
function expand() { panel.classList.remove('sp-collapsed'); thread.hidden = false; toggleBtn.textContent = '▾'; }
|
|
109
|
+
function collapse() { panel.classList.add('sp-collapsed'); thread.hidden = true; toggleBtn.textContent = '▸'; }
|
|
110
|
+
function expandIfContent() { if (panel.classList.contains('sp-collapsed')) expand(); }
|
|
111
|
+
function atBottom() { return thread.scrollHeight - thread.scrollTop - thread.clientHeight < 40; }
|
|
112
|
+
function stick() { if (stickBottom) thread.scrollTop = thread.scrollHeight; }
|
|
113
|
+
thread.addEventListener('scroll', function () { stickBottom = atBottom(); });
|
|
114
|
+
|
|
115
|
+
// ---------- a turn (one user message + Claude's reply/edits) ----------
|
|
116
|
+
function createTurn(turnId, userText, attach) {
|
|
117
|
+
var box = el('div', 'sp-turn'); if (turnId) box.setAttribute('data-turn', turnId);
|
|
118
|
+
if (userText != null) {
|
|
119
|
+
var u = el('div', 'sp-user');
|
|
120
|
+
u.appendChild(el('div', 'sp-bubble', userText));
|
|
121
|
+
if (attach) u.appendChild(el('div', 'sp-attach', '↳ ' + attach));
|
|
122
|
+
box.appendChild(u);
|
|
123
|
+
}
|
|
124
|
+
var group = el('div', 'sp-asst');
|
|
125
|
+
var think = el('div', 'sp-think'); think.hidden = true;
|
|
126
|
+
var thinkToggle = el('div', 'sp-think-toggle', '▸ thinking'); thinkToggle.setAttribute('data-act', 'think');
|
|
127
|
+
var thinkBody = el('div', 'sp-think-body');
|
|
128
|
+
think.appendChild(thinkToggle); think.appendChild(thinkBody);
|
|
129
|
+
var prose = el('div', 'sp-prose');
|
|
130
|
+
var meta = el('div', 'sp-turnmeta'); meta.hidden = true;
|
|
131
|
+
group.appendChild(think); group.appendChild(prose); group.appendChild(meta);
|
|
132
|
+
box.appendChild(group);
|
|
133
|
+
thread.appendChild(box);
|
|
134
|
+
panel.classList.add('sp-has-thread'); // the input's top rule only shows once a conversation exists
|
|
135
|
+
return { id: turnId, box: box, proseEl: prose, thinkEl: thinkBody, thinkWrap: think, metaEl: meta,
|
|
136
|
+
editCount: 0, cardEl: null, cardBody: null, cardTitle: null, textBuf: '', thinkBuf: '', raf: 0, changedCids: [] };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getTurn(turnId) {
|
|
140
|
+
if (turnId && turns[turnId]) return turns[turnId];
|
|
141
|
+
if (pendingTurn && !pendingTurn.id) {
|
|
142
|
+
pendingTurn.id = turnId; if (turnId) { turns[turnId] = pendingTurn; pendingTurn.box.setAttribute('data-turn', turnId); }
|
|
143
|
+
var pt = pendingTurn; pendingTurn = null; return pt;
|
|
144
|
+
}
|
|
145
|
+
var t = createTurn(turnId, null, null); if (turnId) turns[turnId] = t; return t;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function scheduleFlush(rec) {
|
|
149
|
+
if (rec.raf) return;
|
|
150
|
+
rec.raf = requestAnimationFrame(function () {
|
|
151
|
+
rec.raf = 0;
|
|
152
|
+
rec.proseEl.textContent = ''; // re-render the accumulated reply (streaming-safe)
|
|
153
|
+
rec.proseEl.appendChild(renderMarkdown(rec.textBuf));
|
|
154
|
+
if (rec.thinkBuf) { rec.thinkEl.textContent = rec.thinkBuf; rec.thinkWrap.hidden = false; }
|
|
155
|
+
stick();
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------- status chip ----------
|
|
160
|
+
function setChip(f) {
|
|
161
|
+
var c = COLORS[f.state] || '#8A8578';
|
|
162
|
+
led.style.background = c; chip.style.color = c;
|
|
163
|
+
if (f.label) label.textContent = f.label;
|
|
164
|
+
var busy = BUSY.indexOf(f.state) >= 0;
|
|
165
|
+
chip.classList.toggle('sp-busy', busy);
|
|
166
|
+
input.disabled = busy; sendBtn.disabled = busy;
|
|
167
|
+
if (typeof f.cost === 'number') cost.textContent = '$' + f.cost.toFixed(4);
|
|
168
|
+
if (f.state === 'error' && f.turnId) { var te = getTurn(f.turnId); te.box.appendChild(el('div', 'sp-err', (f.label || 'Error') + (f.detail ? ' — ' + f.detail : ''))); stick(); }
|
|
169
|
+
if (f.done && f.turnId) finalizeTurn(getTurn(f.turnId), f);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function finalizeTurn(rec, f) {
|
|
173
|
+
rec.metaEl.hidden = false;
|
|
174
|
+
var edited = rec.editCount > 0;
|
|
175
|
+
rec.box.classList.add(edited ? 'sp-edited' : 'sp-talked');
|
|
176
|
+
rec.metaEl.textContent = '';
|
|
177
|
+
rec.metaEl.appendChild(el('span', 'sp-tag', edited ? ('Saved · ' + rec.editCount + (rec.editCount > 1 ? ' changes' : ' change')) : 'Replied'));
|
|
178
|
+
if (typeof f.cost === 'number') rec.metaEl.appendChild(el('span', 'sp-tagcost', ' · $' + f.cost.toFixed(4)));
|
|
179
|
+
if (edited) {
|
|
180
|
+
var u = el('button', 'sp-undo', 'Undo'); u.setAttribute('data-act', 'undo'); if (rec.id) u.setAttribute('data-turn', rec.id);
|
|
181
|
+
rec.metaEl.appendChild(u);
|
|
182
|
+
}
|
|
183
|
+
lastChangedCids = rec.changedCids.slice();
|
|
184
|
+
persist(); stick();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------- the "what changed" card ----------
|
|
188
|
+
function addEdit(rec, f) {
|
|
189
|
+
rec.editCount += 1;
|
|
190
|
+
(f.cids || []).forEach(function (c) { if (rec.changedCids.indexOf(c) < 0) rec.changedCids.push(c); });
|
|
191
|
+
if (!rec.cardEl) {
|
|
192
|
+
rec.cardEl = el('div', 'sp-card');
|
|
193
|
+
var head = el('div', 'sp-card-head'); head.setAttribute('data-act', 'card');
|
|
194
|
+
rec.cardTitle = el('span', 'sp-card-title', '');
|
|
195
|
+
head.appendChild(rec.cardTitle); head.appendChild(el('span', 'sp-card-chev', '▸'));
|
|
196
|
+
rec.cardBody = el('div', 'sp-card-body'); rec.cardBody.hidden = true;
|
|
197
|
+
rec.cardEl.appendChild(head); rec.cardEl.appendChild(rec.cardBody);
|
|
198
|
+
rec.box.querySelector('.sp-asst').appendChild(rec.cardEl);
|
|
199
|
+
}
|
|
200
|
+
rec.cardTitle.textContent = '✦ changed ' + rec.editCount + (rec.editCount > 1 ? ' things' : ' thing');
|
|
201
|
+
rec.cardBody.appendChild(el('div', 'sp-hunkfile', f.file + ' (+' + f.added + ' / -' + f.removed + ')'));
|
|
202
|
+
(f.hunks || []).forEach(function (h) {
|
|
203
|
+
if (h.oldText) rec.cardBody.appendChild(diffRow('-', h.oldText));
|
|
204
|
+
if (h.newText) rec.cardBody.appendChild(diffRow('+', h.newText));
|
|
205
|
+
});
|
|
206
|
+
stick();
|
|
207
|
+
}
|
|
208
|
+
function diffRow(sign, text) {
|
|
209
|
+
var t = text.length > 400 ? text.slice(0, 400) + '…' : text;
|
|
210
|
+
return el('div', sign === '+' ? 'sp-add' : 'sp-del', sign + ' ' + t);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------- SSE: the live conversation ----------
|
|
214
|
+
var es = new EventSource(API + '/events');
|
|
215
|
+
es.onmessage = function (m) {
|
|
216
|
+
var f; try { f = JSON.parse(m.data); } catch (e) { return; }
|
|
217
|
+
if (f.page && f.page !== location.pathname) return; // frames are page-scoped; ignore other pages' turns
|
|
218
|
+
if (f.type === 'reload') {
|
|
219
|
+
try { sessionStorage.setItem('sp-scroll', String(window.scrollY)); } catch (e) {}
|
|
220
|
+
try { sessionStorage.setItem('sp-flash', JSON.stringify(lastChangedCids)); } catch (e) {}
|
|
221
|
+
persist();
|
|
222
|
+
location.reload();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (f.type === 'assistant_delta') {
|
|
226
|
+
var t = getTurn(f.turnId); expandIfContent();
|
|
227
|
+
if (f.kind === 'thinking') t.thinkBuf += f.text; else t.textBuf += f.text;
|
|
228
|
+
scheduleFlush(t);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (f.type === 'edit') { expandIfContent(); addEdit(getTurn(f.turnId), f); return; }
|
|
232
|
+
setChip(f); // default: a status frame
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// ---------- submit a turn ----------
|
|
236
|
+
panel.querySelector('#sp-form').addEventListener('submit', function (e) {
|
|
237
|
+
e.preventDefault();
|
|
238
|
+
if (chip.classList.contains('sp-busy')) return; // a turn is already running
|
|
239
|
+
var prompt = input.value.trim(); if (!prompt) return;
|
|
240
|
+
var attach = sel ? ((sel.cid ? '#' + sel.cid : sel.selector) + (sel.snippet ? ' — ' + sel.snippet : '')) : null;
|
|
241
|
+
pendingTurn = createTurn(null, prompt, attach);
|
|
242
|
+
expand();
|
|
243
|
+
var payload = { prompt: prompt, page: location.pathname };
|
|
244
|
+
if (sel) { payload.cid = sel.cid; payload.selector = sel.selector; payload.snippet = sel.snippet; }
|
|
245
|
+
setChip({ state: 'thinking', label: 'Sending…' });
|
|
246
|
+
fetch(API + '/turn', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
|
|
247
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
248
|
+
.then(function (j) {
|
|
249
|
+
if (j && j.turnId && pendingTurn && !pendingTurn.id) {
|
|
250
|
+
pendingTurn.id = j.turnId; turns[j.turnId] = pendingTurn; pendingTurn.box.setAttribute('data-turn', j.turnId); pendingTurn = null;
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
.catch(function () { setChip({ state: 'error', label: 'Bridge unreachable' }); });
|
|
254
|
+
input.value = ''; clearScope();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// ---------- sling: hand a terminal-ready instruction to the clipboard ----------
|
|
258
|
+
panel.querySelector('#sp-sling').addEventListener('click', function () {
|
|
259
|
+
var instr = input.value.trim();
|
|
260
|
+
var where = location.pathname.replace(/^\//, '') || 'index.html';
|
|
261
|
+
var scope = sel ? (' ' + (sel.cid ? '#' + sel.cid : sel.selector) + (sel.snippet ? ' — "' + sel.snippet + '"' : '')) : '';
|
|
262
|
+
var msg = 'In ' + where + scope + (instr ? '\n\n' + instr : '');
|
|
263
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
264
|
+
navigator.clipboard.writeText(msg).then(
|
|
265
|
+
function () { setChip({ state: 'done', label: 'Slung → paste in your terminal' }); },
|
|
266
|
+
function () { setChip({ state: 'error', label: 'Clipboard blocked' }); }
|
|
267
|
+
);
|
|
268
|
+
} else {
|
|
269
|
+
setChip({ state: 'error', label: 'Clipboard unavailable' });
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// ---------- delegated interactions (survive transcript rehydrate) ----------
|
|
274
|
+
thread.addEventListener('click', function (e) {
|
|
275
|
+
var node = e.target.closest ? e.target.closest('[data-act]') : null;
|
|
276
|
+
if (!node) return;
|
|
277
|
+
var act = node.getAttribute('data-act');
|
|
278
|
+
if (act === 'think') {
|
|
279
|
+
var body = node.parentNode.querySelector('.sp-think-body');
|
|
280
|
+
var open = node.classList.toggle('sp-open');
|
|
281
|
+
node.textContent = (open ? '▾' : '▸') + ' thinking';
|
|
282
|
+
body.style.display = open ? 'block' : 'none';
|
|
283
|
+
} else if (act === 'card') {
|
|
284
|
+
var cardBody = node.parentNode.querySelector('.sp-card-body');
|
|
285
|
+
var chev = node.querySelector('.sp-card-chev');
|
|
286
|
+
cardBody.hidden = !cardBody.hidden;
|
|
287
|
+
if (chev) chev.textContent = cardBody.hidden ? '▸' : '▾';
|
|
288
|
+
} else if (act === 'undo') {
|
|
289
|
+
var id = node.getAttribute('data-turn');
|
|
290
|
+
node.disabled = true; node.textContent = 'Undoing…';
|
|
291
|
+
fetch(API + '/undo', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ turnId: id, page: location.pathname }) })
|
|
292
|
+
.catch(function () { node.textContent = 'Undo failed'; });
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
toggleBtn.addEventListener('click', function () { panel.classList.contains('sp-collapsed') ? expand() : collapse(); });
|
|
296
|
+
|
|
297
|
+
// minimize to a small status pill (gets the toolbar out of the way); click the pill to restore
|
|
298
|
+
var minBtn = panel.querySelector('#sp-min');
|
|
299
|
+
minBtn.addEventListener('click', function (e) { e.stopPropagation(); panel.classList.add('sp-min'); });
|
|
300
|
+
panel.querySelector('#sp-head').addEventListener('click', function () { if (panel.classList.contains('sp-min')) panel.classList.remove('sp-min'); });
|
|
301
|
+
|
|
302
|
+
// ---------- click-to-scope ----------
|
|
303
|
+
function cssPath(t) {
|
|
304
|
+
if (t.id) return '#' + CSS.escape(t.id);
|
|
305
|
+
var parts = [];
|
|
306
|
+
while (t && t.nodeType === 1 && t !== document.body && parts.length < 6) {
|
|
307
|
+
var p = t.tagName.toLowerCase(), par = t.parentNode;
|
|
308
|
+
if (par) { var sib = Array.prototype.filter.call(par.children, function (c) { return c.tagName === t.tagName; }); if (sib.length > 1) p += ':nth-of-type(' + (sib.indexOf(t) + 1) + ')'; }
|
|
309
|
+
parts.unshift(p); t = t.parentNode;
|
|
310
|
+
}
|
|
311
|
+
return parts.join(' > ');
|
|
312
|
+
}
|
|
313
|
+
function clearScope() { sel = null; targetTag.hidden = true; }
|
|
314
|
+
function onOver(e) { if (picking && !panel.contains(e.target)) e.target.classList.add('sp-hl'); }
|
|
315
|
+
function onOut(e) { if (e.target.classList) e.target.classList.remove('sp-hl'); }
|
|
316
|
+
function onClick(e) {
|
|
317
|
+
if (!picking || panel.contains(e.target)) return;
|
|
318
|
+
e.preventDefault(); e.stopPropagation();
|
|
319
|
+
var t = e.target; t.classList.remove('sp-hl');
|
|
320
|
+
var anc = t.closest('[data-cid]'), cid = anc ? anc.getAttribute('data-cid') : null;
|
|
321
|
+
var snip = (t.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 80);
|
|
322
|
+
sel = { cid: cid, selector: cid ? null : cssPath(t), snippet: snip };
|
|
323
|
+
targetTag.hidden = false; targetTag.textContent = '⌖ ' + (cid ? '#' + cid : sel.selector) + (snip ? ' — ' + snip : '');
|
|
324
|
+
stopPick(); input.focus();
|
|
325
|
+
}
|
|
326
|
+
function startPick() { picking = true; pickBtn.classList.add('sp-on'); document.body.classList.add('sp-picking'); }
|
|
327
|
+
function stopPick() { picking = false; pickBtn.classList.remove('sp-on'); document.body.classList.remove('sp-picking'); }
|
|
328
|
+
pickBtn.addEventListener('click', function () { picking ? stopPick() : startPick(); });
|
|
329
|
+
document.addEventListener('mouseover', onOver, true);
|
|
330
|
+
document.addEventListener('mouseout', onOut, true);
|
|
331
|
+
document.addEventListener('click', onClick, true);
|
|
332
|
+
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { stopPick(); clearScope(); } });
|
|
333
|
+
|
|
334
|
+
// ---------- ✎ edit-in-place — change text directly, no AI (the "Hands") ----------
|
|
335
|
+
// Clicking a LEAF record (a data-cid element with no data-cid descendants) makes it editable;
|
|
336
|
+
// committing splices its new inner HTML straight into the file via /write. No turn, no Claude.
|
|
337
|
+
var editing = false, current = null; // current = { el, cid, original, onKey, onBlur }
|
|
338
|
+
|
|
339
|
+
function markEditables(on) {
|
|
340
|
+
Array.prototype.forEach.call(document.querySelectorAll('[data-cid]'), function (n) {
|
|
341
|
+
if (panel.contains(n)) return;
|
|
342
|
+
var leaf = !n.querySelector('[data-cid]'); // never make a big container editable, only leaf records
|
|
343
|
+
if (on && leaf) n.classList.add('sp-editable'); else n.classList.remove('sp-editable');
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
function startEditMode() { editing = true; editBtn.classList.add('sp-on'); document.body.classList.add('sp-editing'); markEditables(true); if (picking) stopPick(); }
|
|
347
|
+
function stopEditMode() { if (current) commitEdit(); editing = false; editBtn.classList.remove('sp-on'); document.body.classList.remove('sp-editing'); markEditables(false); rowctl.hidden = true; clearDrag(); }
|
|
348
|
+
editBtn.addEventListener('click', function () { editing ? stopEditMode() : startEditMode(); });
|
|
349
|
+
|
|
350
|
+
function detach(rec) {
|
|
351
|
+
rec.el.removeEventListener('keydown', rec.onKey);
|
|
352
|
+
rec.el.removeEventListener('blur', rec.onBlur);
|
|
353
|
+
rec.el.removeAttribute('contenteditable');
|
|
354
|
+
}
|
|
355
|
+
function beginEdit(elm) {
|
|
356
|
+
if (current) commitEdit();
|
|
357
|
+
var rec = { el: elm, cid: elm.getAttribute('data-cid'), original: elm.innerHTML };
|
|
358
|
+
rec.onKey = function (ev) {
|
|
359
|
+
if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); elm.blur(); } // Enter commits
|
|
360
|
+
else if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); cancelEdit(); } // Esc reverts
|
|
361
|
+
};
|
|
362
|
+
rec.onBlur = function () { commitEdit(); };
|
|
363
|
+
elm.setAttribute('contenteditable', 'true');
|
|
364
|
+
elm.addEventListener('keydown', rec.onKey);
|
|
365
|
+
elm.addEventListener('blur', rec.onBlur);
|
|
366
|
+
current = rec; elm.focus();
|
|
367
|
+
}
|
|
368
|
+
function cancelEdit() { if (!current) return; var rec = current; current = null; rec.el.innerHTML = rec.original; detach(rec); }
|
|
369
|
+
function commitEdit() {
|
|
370
|
+
if (!current) return;
|
|
371
|
+
var rec = current; current = null; detach(rec);
|
|
372
|
+
var next = rec.el.innerHTML;
|
|
373
|
+
if (next === rec.original) return; // nothing changed
|
|
374
|
+
fetch(API + '/write', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
375
|
+
body: JSON.stringify({ page: location.pathname, cid: rec.cid, html: next }) })
|
|
376
|
+
.then(function (r) { if (!r.ok) throw new Error('write rejected'); return r.json(); })
|
|
377
|
+
.then(function () { directDone('Saved'); rec.el.classList.add('sp-saved'); setTimeout(function () { rec.el.classList.remove('sp-saved'); }, 1300); })
|
|
378
|
+
.catch(function () { rec.el.innerHTML = rec.original; setChip({ state: 'error', label: 'Couldn’t save that edit' }); }); // keep file & page in sync on failure
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// capture-phase so a click on a leaf record edits it instead of following links / scoping
|
|
382
|
+
document.addEventListener('click', function (e) {
|
|
383
|
+
if (!editing || panel.contains(e.target)) return;
|
|
384
|
+
var elm = e.target.closest ? e.target.closest('[data-cid]') : null;
|
|
385
|
+
if (!elm || elm.querySelector('[data-cid]')) return; // only leaf records are editable
|
|
386
|
+
if (current && current.el === elm) return; // already editing this one — let the caret move
|
|
387
|
+
e.preventDefault(); e.stopPropagation();
|
|
388
|
+
beginEdit(elm);
|
|
389
|
+
}, true);
|
|
390
|
+
|
|
391
|
+
// ---------- ✎ Hands: drag-to-reorder + delete (direct file ops, no AI) ----------
|
|
392
|
+
// A small handle cluster floats over the hovered record; the grip drags, the × deletes.
|
|
393
|
+
var rowctl = el('div'); rowctl.id = 'sp-rowctl'; rowctl.hidden = true;
|
|
394
|
+
var grip = el('span', 'sp-grip', '⠿'); grip.setAttribute('draggable', 'true'); grip.title = 'Drag to reorder';
|
|
395
|
+
var delBtn = el('button', 'sp-del', '×'); delBtn.type = 'button'; delBtn.title = 'Delete this block';
|
|
396
|
+
rowctl.appendChild(grip); rowctl.appendChild(delBtn);
|
|
397
|
+
document.body.appendChild(rowctl);
|
|
398
|
+
|
|
399
|
+
var hoverRow = null, hideT = 0;
|
|
400
|
+
var dragEl = null, dragCid = null, dropTarget = null, dropMode = 'before';
|
|
401
|
+
|
|
402
|
+
function showCtl(elm) {
|
|
403
|
+
hoverRow = elm;
|
|
404
|
+
var r = elm.getBoundingClientRect();
|
|
405
|
+
rowctl.style.top = (r.top + window.scrollY) + 'px';
|
|
406
|
+
rowctl.style.left = (r.left + window.scrollX) + 'px';
|
|
407
|
+
rowctl.classList.toggle('sp-ctl-below', r.top < 40); // near the (sticky) top → sit just inside, not above
|
|
408
|
+
rowctl.hidden = false;
|
|
409
|
+
}
|
|
410
|
+
function hideCtlSoon() { clearTimeout(hideT); hideT = setTimeout(function () { if (!dragEl) { rowctl.hidden = true; hoverRow = null; } }, 220); }
|
|
411
|
+
function clearDrag() {
|
|
412
|
+
document.body.classList.remove('sp-dragging');
|
|
413
|
+
if (dropTarget) dropTarget.classList.remove('sp-drop-before', 'sp-drop-after');
|
|
414
|
+
dragEl = null; dragCid = null; dropTarget = null;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
document.addEventListener('mouseover', function (e) {
|
|
418
|
+
if (!editing || dragEl || current) return; // no handle while a text edit is in progress
|
|
419
|
+
if (rowctl.contains(e.target)) { clearTimeout(hideT); return; }
|
|
420
|
+
var elm = e.target.closest ? e.target.closest('.sp-editable') : null;
|
|
421
|
+
if (elm && !panel.contains(elm)) { clearTimeout(hideT); showCtl(elm); }
|
|
422
|
+
}, true);
|
|
423
|
+
document.addEventListener('mouseout', function (e) {
|
|
424
|
+
if (!editing) return;
|
|
425
|
+
var to = e.relatedTarget;
|
|
426
|
+
if (to && (rowctl.contains(to) || (hoverRow && hoverRow.contains(to)))) return;
|
|
427
|
+
hideCtlSoon();
|
|
428
|
+
}, true);
|
|
429
|
+
|
|
430
|
+
delBtn.addEventListener('click', function (e) {
|
|
431
|
+
e.preventDefault(); e.stopPropagation();
|
|
432
|
+
if (!hoverRow) return;
|
|
433
|
+
var cid = hoverRow.getAttribute('data-cid');
|
|
434
|
+
hoverRow.remove(); rowctl.hidden = true; hoverRow = null; // optimistic; resync on failure
|
|
435
|
+
domOp({ op: 'delete', cid: cid }, 'Deleted');
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
grip.addEventListener('dragstart', function (e) {
|
|
439
|
+
if (!hoverRow || current) { e.preventDefault(); return; }
|
|
440
|
+
dragEl = hoverRow; dragCid = dragEl.getAttribute('data-cid');
|
|
441
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
442
|
+
try { e.dataTransfer.setData('text/plain', dragCid); } catch (x) {}
|
|
443
|
+
try { e.dataTransfer.setDragImage(dragEl, 14, 14); } catch (x) {} // the BLOCK follows the cursor, not the 8px grip
|
|
444
|
+
document.body.classList.add('sp-dragging');
|
|
445
|
+
setTimeout(function () { rowctl.hidden = true; }, 0); // hide the handle AFTER the drag latches (don't kill the source)
|
|
446
|
+
});
|
|
447
|
+
grip.addEventListener('dragend', clearDrag);
|
|
448
|
+
document.addEventListener('dragover', function (e) {
|
|
449
|
+
if (!dragEl) return;
|
|
450
|
+
var elm = e.target.closest ? e.target.closest('.sp-editable') : null;
|
|
451
|
+
if (!elm || panel.contains(elm) || elm === dragEl || dragEl.contains(elm) || elm.parentNode !== dragEl.parentNode) return; // reorder among siblings only
|
|
452
|
+
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
|
|
453
|
+
if (dropTarget && dropTarget !== elm) dropTarget.classList.remove('sp-drop-before', 'sp-drop-after');
|
|
454
|
+
dropTarget = elm;
|
|
455
|
+
var r = elm.getBoundingClientRect();
|
|
456
|
+
dropMode = (e.clientY < r.top + r.height / 2) ? 'before' : 'after';
|
|
457
|
+
elm.classList.toggle('sp-drop-before', dropMode === 'before');
|
|
458
|
+
elm.classList.toggle('sp-drop-after', dropMode === 'after');
|
|
459
|
+
}, true);
|
|
460
|
+
document.addEventListener('drop', function (e) {
|
|
461
|
+
if (!dragEl || !dropTarget) { clearDrag(); return; }
|
|
462
|
+
e.preventDefault();
|
|
463
|
+
var moved = dragEl, cid = dragCid, tgt = dropTarget, mode = dropMode, tcid = tgt.getAttribute('data-cid');
|
|
464
|
+
tgt.classList.remove('sp-drop-before', 'sp-drop-after');
|
|
465
|
+
if (mode === 'before') tgt.parentNode.insertBefore(moved, tgt); // optimistic DOM move
|
|
466
|
+
else tgt.parentNode.insertBefore(moved, tgt.nextSibling);
|
|
467
|
+
clearDrag();
|
|
468
|
+
domOp({ op: 'move', cid: cid, target: tcid, mode: mode }, 'Moved');
|
|
469
|
+
}, true);
|
|
470
|
+
|
|
471
|
+
// shared POST for structural ops; on failure resync the page from disk
|
|
472
|
+
function domOp(payload, okLabel) {
|
|
473
|
+
payload.page = location.pathname;
|
|
474
|
+
fetch(API + '/dom', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
|
|
475
|
+
.then(function (r) { if (!r.ok) throw new Error(); return r.json(); })
|
|
476
|
+
.then(function () { directDone(okLabel); })
|
|
477
|
+
.catch(function () { setChip({ state: 'error', label: 'Couldn’t save — reloading' }); setTimeout(function () { location.reload(); }, 600); });
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// undo affordance after ANY direct edit (text · delete · move) — one level, server-snapshotted
|
|
481
|
+
function directDone(label) { setChip({ state: 'done', label: label + ' · no AI' }); undoBtn.hidden = false; }
|
|
482
|
+
undoBtn.addEventListener('click', function () {
|
|
483
|
+
undoBtn.hidden = true;
|
|
484
|
+
fetch(API + '/undo-direct', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ page: location.pathname }) })
|
|
485
|
+
.then(function (r) { if (!r.ok) throw new Error(); /* server restores → watcher → reload */ })
|
|
486
|
+
.catch(function () { setChip({ state: 'error', label: 'Nothing to undo' }); });
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// ---------- first-run welcome — a one-time, on-page tour of the three tools ----------
|
|
490
|
+
// Shows once per browser. Gated on BOTH localStorage (across sessions) and sessionStorage
|
|
491
|
+
// (survives the live-reload even when localStorage is blocked, e.g. private mode).
|
|
492
|
+
var WELCOMED = 'sp-welcomed:v1';
|
|
493
|
+
function welcomed() {
|
|
494
|
+
try { if (localStorage.getItem(WELCOMED) === '1') return true; } catch (e) {}
|
|
495
|
+
try { if (sessionStorage.getItem(WELCOMED) === '1') return true; } catch (e) {}
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
function setWelcomed() {
|
|
499
|
+
try { localStorage.setItem(WELCOMED, '1'); } catch (e) {}
|
|
500
|
+
try { sessionStorage.setItem(WELCOMED, '1'); } catch (e) {}
|
|
501
|
+
}
|
|
502
|
+
function pulsePanel() { panel.classList.add('sp-attn'); setTimeout(function () { panel.classList.remove('sp-attn'); }, 1600); }
|
|
503
|
+
|
|
504
|
+
function maybeWelcome() {
|
|
505
|
+
if (welcomed()) return;
|
|
506
|
+
var w = el('div'); w.id = 'sp-welcome'; w.setAttribute('role', 'dialog'); w.setAttribute('aria-modal', 'true'); w.setAttribute('aria-label', 'Welcome to Sandpaper');
|
|
507
|
+
if (hostSkin) for (var sk in hostSkin) w.style.setProperty(sk, hostSkin[sk]); // the tour wears the host skin too
|
|
508
|
+
w.innerHTML = // static template — no untrusted data
|
|
509
|
+
'<div class="sp-w-card">' +
|
|
510
|
+
'<div class="sp-w-head"><span class="sp-w-mark">Sand<span>paper</span></span>' +
|
|
511
|
+
'<button type="button" class="sp-w-x" aria-label="Close">×</button></div>' +
|
|
512
|
+
'<div class="sp-w-body">' +
|
|
513
|
+
'<h2 class="sp-w-title">This page is your project’s brain.</h2>' +
|
|
514
|
+
'<p class="sp-w-lede">It mirrors where the project stands — and you refine it right here, in the page. Three ways:</p>' +
|
|
515
|
+
'<ul class="sp-w-tools">' +
|
|
516
|
+
'<li><span class="sp-w-g sp-w-sand">Sand</span><div><b>Say a change</b><span class="d">Describe it in plain words — Claude edits the page, scoped to whatever you point at.</span></div></li>' +
|
|
517
|
+
'<li><span class="sp-w-g sp-w-hands">✎</span><div><b>Use your hands</b><span class="d">Edit text, drag to reorder, or delete — directly, no AI.</span></div></li>' +
|
|
518
|
+
'<li><span class="sp-w-g sp-w-sling">>_</span><div><b>Sling to terminal</b><span class="d">Copy a ready-made instruction for bigger, cross-page work.</span></div></li>' +
|
|
519
|
+
'</ul>' +
|
|
520
|
+
'<p class="sp-w-tip">Try first: re-skin it to your brand with <code>/sandpaper:theme #yourhex</code>, or hit <b>✎</b> and rewrite the line up top.</p>' +
|
|
521
|
+
'</div>' +
|
|
522
|
+
'<div class="sp-w-foot"><span class="sp-w-point">your tools live down here ↘</span>' +
|
|
523
|
+
'<button type="button" class="sp-w-go">Start refining →</button></div>' +
|
|
524
|
+
'</div>';
|
|
525
|
+
document.body.appendChild(w);
|
|
526
|
+
requestAnimationFrame(function () { w.classList.add('sp-w-in'); });
|
|
527
|
+
function close() {
|
|
528
|
+
setWelcomed();
|
|
529
|
+
w.classList.remove('sp-w-in');
|
|
530
|
+
document.removeEventListener('keydown', onKey, true);
|
|
531
|
+
setTimeout(function () { w.remove(); pulsePanel(); }, 240); // then nudge the eye to the real toolbar
|
|
532
|
+
}
|
|
533
|
+
function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); } }
|
|
534
|
+
w.querySelector('.sp-w-go').addEventListener('click', close);
|
|
535
|
+
w.querySelector('.sp-w-x').addEventListener('click', close);
|
|
536
|
+
w.addEventListener('click', function (e) { if (e.target === w) close(); }); // click the backdrop to dismiss
|
|
537
|
+
document.addEventListener('keydown', onKey, true);
|
|
538
|
+
w.querySelector('.sp-w-go').focus();
|
|
539
|
+
}
|
|
540
|
+
window.addEventListener('load', maybeWelcome);
|
|
541
|
+
|
|
542
|
+
// ---------- persistence + rehydrate (survive the live-reload) ----------
|
|
543
|
+
function persist() { try { sessionStorage.setItem(SKEY, thread.innerHTML); } catch (e) {} }
|
|
544
|
+
|
|
545
|
+
window.addEventListener('load', function () {
|
|
546
|
+
try { var y = sessionStorage.getItem('sp-scroll'); if (y !== null) { window.scrollTo(0, parseInt(y, 10)); sessionStorage.removeItem('sp-scroll'); } } catch (e) {}
|
|
547
|
+
// rehydrate the conversation (our own serialized, escaped DOM)
|
|
548
|
+
try { var saved = sessionStorage.getItem(SKEY); if (saved) { thread.innerHTML = saved; panel.classList.add('sp-has-thread'); expand(); thread.scrollTop = thread.scrollHeight; } } catch (e) {}
|
|
549
|
+
// flash the changed elements so the eye lands on what moved
|
|
550
|
+
try {
|
|
551
|
+
var cids = JSON.parse(sessionStorage.getItem('sp-flash') || '[]'); sessionStorage.removeItem('sp-flash');
|
|
552
|
+
var first = null;
|
|
553
|
+
cids.forEach(function (c) {
|
|
554
|
+
var q = '[data-cid="' + (window.CSS && CSS.escape ? CSS.escape(c) : c) + '"]';
|
|
555
|
+
Array.prototype.forEach.call(document.querySelectorAll(q), function (n) {
|
|
556
|
+
if (panel.contains(n)) return;
|
|
557
|
+
n.classList.add('sp-flash'); if (!first) first = n;
|
|
558
|
+
setTimeout(function () { n.classList.remove('sp-flash'); }, 2200);
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
if (first) first.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
562
|
+
} catch (e) {}
|
|
563
|
+
});
|
|
564
|
+
})();
|