@lovek4d/kickflip 0.0.0-stage → 0.3.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/CONTEXT.md +513 -0
- package/LICENSE +57 -0
- package/README.md +64 -2
- package/SKILL.md +142 -0
- package/bin/doctor.mjs +4 -0
- package/bin/install.mjs +3 -0
- package/bin/kickflip.mjs +180 -0
- package/bin/statusline.mjs +5 -0
- package/bin/wait-events.mjs +3 -0
- package/board/arch-review.mjs +5 -0
- package/board/bin/agent.mjs +2 -0
- package/board/bin/board.mjs +379 -0
- package/board/bin/builder-stop-hook.mjs +6 -0
- package/board/build-script.mjs +1 -0
- package/board/domain.mjs +5 -0
- package/board/on-host.mjs +1 -0
- package/board/public/app-arch.js +75 -0
- package/board/public/app-asks.js +188 -0
- package/board/public/app-banner.js +48 -0
- package/board/public/app-cards.js +393 -0
- package/board/public/app-core.js +130 -0
- package/board/public/app-header.js +126 -0
- package/board/public/app-lanes.js +142 -0
- package/board/public/app-needs.js +31 -0
- package/board/public/app-snapshot.js +131 -0
- package/board/public/app-threads.js +167 -0
- package/board/public/app.html +86 -0
- package/board/public/app.js +438 -0
- package/board/public/attach.js +84 -0
- package/board/public/dialogs.js +358 -0
- package/board/public/hub-kit.js +168 -0
- package/board/public/hub.css +170 -0
- package/board/public/kickflip.png +0 -0
- package/board/public/linear-key.js +141 -0
- package/board/public/prompts.js +64 -0
- package/board/public/prototype-kit.js +193 -0
- package/board/public/prototype-switcher.js +226 -0
- package/board/public/prototype.html +55 -0
- package/board/public/router.js +226 -0
- package/board/public/rules-editor.js +127 -0
- package/board/public/shell.js +298 -0
- package/board/public/sidebar.css +219 -0
- package/board/public/sidebar.js +359 -0
- package/board/public/style.css +665 -0
- package/board/public/toasts.js +181 -0
- package/board/public/ui-cards.js +218 -0
- package/board/public/ui-enums.js +10 -0
- package/board/public/ui-header.js +93 -0
- package/board/public/ui-hub.js +112 -0
- package/board/public/ui-links.js +150 -0
- package/board/public/ui-markdown.js +80 -0
- package/board/public/ui-order.js +18 -0
- package/board/public/ui-page-header.js +65 -0
- package/board/public/ui-prompts.js +39 -0
- package/board/public/ui-questions.js +151 -0
- package/board/public/ui-sidebar.js +215 -0
- package/board/public/ui-snapshot.js +62 -0
- package/board/public/ui-threads.js +90 -0
- package/board/public/ui.js +247 -0
- package/board/public/view-frame.js +93 -0
- package/board/public/view-hub-settings.js +282 -0
- package/board/public/view-hub.js +150 -0
- package/board/public/view-notes.js +32 -0
- package/board/public/view-polling.js +87 -0
- package/board/public/view-questions.js +411 -0
- package/board/public/view-settings.js +383 -0
- package/board/review.mjs +1 -0
- package/board/server.mjs +17 -0
- package/board/store.mjs +15 -0
- package/board/tickets.mjs +3 -0
- package/board/worktree.mjs +15 -0
- package/docs/hub-session.md +11 -0
- package/hooks-plugin/.claude-plugin/plugin.json +5 -0
- package/hooks-plugin/hooks/codex-share-auto.mjs +23 -0
- package/hooks-plugin/hooks/hooks.json +54 -0
- package/hooks-plugin/hooks/usage-gate.mjs +18 -0
- package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
- package/hub/awake.mjs +1 -0
- package/hub/board-repo.mjs +1 -0
- package/hub/boards.mjs +4 -0
- package/hub/close-board.mjs +1 -0
- package/hub/duration.mjs +1 -0
- package/hub/files.mjs +1 -0
- package/hub/github.mjs +4 -0
- package/hub/home.mjs +1 -0
- package/hub/hub.mjs +19 -0
- package/hub/icon-180.png +0 -0
- package/hub/icon-192.png +0 -0
- package/hub/icon-512.png +0 -0
- package/hub/icons.mjs +1 -0
- package/hub/linear.mjs +2 -0
- package/hub/live.mjs +2 -0
- package/hub/locks.mjs +1 -0
- package/hub/new-board.mjs +2 -0
- package/hub/permission-hook.mjs +3 -0
- package/hub/pr-report.mjs +1 -0
- package/hub/presence.mjs +1 -0
- package/hub/prompts.mjs +2 -0
- package/hub/push.mjs +2 -0
- package/hub/readiness.mjs +2 -0
- package/hub/repos.mjs +3 -0
- package/hub/restart-board.mjs +1 -0
- package/hub/routes.mjs +1 -0
- package/hub/rules.mjs +1 -0
- package/hub/schedule.mjs +1 -0
- package/hub/sessions.mjs +2 -0
- package/hub/shell.mjs +2 -0
- package/hub/slack.mjs +2 -0
- package/hub/stream.mjs +19 -0
- package/hub/sw.js +144 -0
- package/hub/trust.mjs +2 -0
- package/hub/try.mjs +4 -0
- package/hub/upgrade.mjs +1 -0
- package/hub/usage.mjs +3 -0
- package/hub/views.mjs +1 -0
- package/package.json +26 -4
- package/shared/codex.mjs +1 -0
- package/shared/colors.css +15 -0
- package/shared/fake-gh.mjs +54 -0
- package/shared/repos.mjs +1 -0
- package/shared/service.mjs +33 -0
- package/shared/skills.mjs +2 -0
- package/shared/usage-gate.mjs +1 -0
- package/starter/cross-site.mjs +1 -0
- package/starter/public/claude.js +68 -0
- package/starter/public/index.html +49 -0
- package/starter/server.mjs +4 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// The floating ‹ take › pill every prototype page switches its takes with (docs/adr/0015-prototypes-come-on-request.md).
|
|
2
|
+
// Load it with a relative src after claude.js, on a page whose header leads with the back arrow, then call
|
|
3
|
+
// const sw = PrototypeSwitcher.mount({ takes: { A: 'A. Name', … }, initial: 'A', render(take) {…}, above: el? })
|
|
4
|
+
// render runs at mount and on each switch; sw.take is the current key. ←/→ switch unless a field has focus, and the
|
|
5
|
+
// take stays in ?variant=. With ?q=<owner question id>, the pill offers the take as that question's answer: a take
|
|
6
|
+
// is pickable when an option's id equals its key. Pick sends the owner-answer event questions.html sends, with the
|
|
7
|
+
// note typed above the pill, then moves on through BoardUI.nextQuestion (ui-questions.js).
|
|
8
|
+
// PrototypeSwitcher.current is the mounted switcher, whose go(key) shows another take in place; Waiting on you uses it
|
|
9
|
+
// to switch its full-width frame without a reload (view-questions.js inPlace).
|
|
10
|
+
// With ?take=<key> the page shows that take alone, with no header or pill, as questions.html frames it
|
|
11
|
+
// (docs/adr/0048-a-questions-takes-show-inline.md); a page hides its own extras under html.ps-embed. There main's
|
|
12
|
+
// bottom padding, kept clear for the pill, shrinks to 12px whatever the page sets, since questions.html sizes the frame
|
|
13
|
+
// to the page's height.
|
|
14
|
+
(function () {
|
|
15
|
+
const esc = (t) => String(t ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
16
|
+
|
|
17
|
+
const takeAt = (keys, current, step) => {
|
|
18
|
+
const n = keys.length;
|
|
19
|
+
return keys[((Math.max(0, keys.indexOf(current)) + step) % n + n) % n];
|
|
20
|
+
};
|
|
21
|
+
const startTake = (keys, wanted, initial) => (keys.includes(wanted) ? wanted : keys.includes(initial) ? initial : keys[0]);
|
|
22
|
+
|
|
23
|
+
// The owner's current pick is the latest answer that chose an option; a note-only answer keeps the earlier pick.
|
|
24
|
+
const pickedChoice = (question) => [...(question?.answers || [])].reverse().find((a) => a.choice)?.choice ?? null;
|
|
25
|
+
// none: no question, or the take is not one of its options. pick: nothing picked yet. picked: this take is the
|
|
26
|
+
// owner's pick. change: another take is.
|
|
27
|
+
function pickState(question, take) {
|
|
28
|
+
if (!question) return { kind: 'none' };
|
|
29
|
+
const option = (question.options || []).find((o) => o.id === take);
|
|
30
|
+
const picked = pickedChoice(question);
|
|
31
|
+
if (!option) return { kind: 'none', picked };
|
|
32
|
+
if (!picked) return { kind: 'pick', option };
|
|
33
|
+
return { kind: picked === take ? 'picked' : 'change', option, picked };
|
|
34
|
+
}
|
|
35
|
+
// Matches ui-questions.js choiceLabel for a single-choice question.
|
|
36
|
+
const answerLabel = (option) => `${/^[A-Z][0-9]?$/i.test(option.id) ? `${option.id}. ` : ''}${option.label}`;
|
|
37
|
+
const questionUrl = (q) => (/^prototype-[\w-]+\.html(?:[?#]|$)/.test(q.url || '') ? q.url : `questions.html#${encodeURIComponent(q.id)}`);
|
|
38
|
+
// questions.html keeps each question's unsent {choice, note} under this key; a pick sent here takes its note and clears it.
|
|
39
|
+
const draftKey = (qid) => 'qdraft:' + qid;
|
|
40
|
+
const readDraft = (qid) => { try { return JSON.parse(localStorage.getItem(draftKey(qid)) || '{}'); } catch { return {}; } };
|
|
41
|
+
// In place of this page: framed in the app shell, the shell shows the URL's view in place (board/public/shell.js
|
|
42
|
+
// AppShell.open); alone, or for a URL that isn't a view, the page loads it.
|
|
43
|
+
function replaceWith(url) {
|
|
44
|
+
let app = null;
|
|
45
|
+
try { app = window.top !== window ? window.top.AppShell : null; } catch { app = null; }
|
|
46
|
+
if (!app?.open?.(String(url), { replace: true })) location.replace(url);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Prototype pages need only claude.js and this switcher. In the shell, its BoardUI also owns the board's notice.
|
|
50
|
+
let questionsUI = null;
|
|
51
|
+
const sharedQuestions = (site) => (questionsUI ||= (async () => {
|
|
52
|
+
try { if (window.top !== window && window.top.BoardUI?.nextQuestion) return window.top.BoardUI; } catch {}
|
|
53
|
+
for (const [file, key] of [['ui.js', 'esc'], ['ui-links.js', 'linkText'], ['ui-questions.js', 'nextQuestion']]) {
|
|
54
|
+
if (globalThis.BoardUI?.[key]) continue;
|
|
55
|
+
await new Promise((resolve, reject) => {
|
|
56
|
+
const script = document.createElement('script');
|
|
57
|
+
script.src = site.url(file);
|
|
58
|
+
script.onload = resolve;
|
|
59
|
+
script.onerror = reject;
|
|
60
|
+
document.head.append(script);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return globalThis.BoardUI;
|
|
64
|
+
})().catch((e) => { questionsUI = null; throw e; }));
|
|
65
|
+
|
|
66
|
+
function pillHtml({ name, pick = { kind: 'none' }, busy = false }) {
|
|
67
|
+
const control = pick.kind === 'picked' ? '<span class="ps-picked">Picked</span>'
|
|
68
|
+
: pick.kind === 'pick' || pick.kind === 'change'
|
|
69
|
+
? `<button type="button" class="ps-pick" data-ps="pick"${busy ? ' disabled' : ''}>${busy ? 'Sending…' : pick.kind === 'pick' ? 'Pick this' : 'Change pick'}</button>`
|
|
70
|
+
: '';
|
|
71
|
+
return `<button type="button" data-ps="prev" aria-label="Previous take">‹</button><span class="ps-name" title="${esc(name)}">${esc(name)}</span><button type="button" data-ps="next" aria-label="Next take">›</button>${control}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const CSS = `
|
|
75
|
+
.ps-switch { position:fixed; left:16px; right:16px; bottom:calc(var(--app-bottom, 0px) + 14px + env(safe-area-inset-bottom)); z-index:30;
|
|
76
|
+
display:flex; flex-direction:column; align-items:center; gap:6px; pointer-events:none; }
|
|
77
|
+
.ps-switch > * { pointer-events:auto; max-width:100%; min-width:0; box-sizing:border-box; }
|
|
78
|
+
.ps-switch .pill, .ps-pill { display:flex; align-items:center; gap:2px; background:#000d; border:1px solid #fff3; border-radius:999px; padding:4px;
|
|
79
|
+
-webkit-backdrop-filter:blur(12px); backdrop-filter:blur(12px); font:600 13px -apple-system, system-ui, sans-serif; }
|
|
80
|
+
.ps-switch .pill button, .ps-pill button { color:#fff; background:transparent; border:0; border-radius:999px; padding:7px 11px; font:inherit; white-space:nowrap; cursor:pointer; }
|
|
81
|
+
.ps-pill button { flex-shrink:0; min-width:44px; min-height:44px; }
|
|
82
|
+
.ps-switch .pill button.on { background:#fff; color:#000; }
|
|
83
|
+
.ps-name { flex:0 1 auto; padding:7px 6px; color:#fff; white-space:nowrap; min-width:0; text-align:center; overflow:hidden; text-overflow:ellipsis; }
|
|
84
|
+
.ps-pill .ps-pick { background:#fff; color:#000; margin-left:2px; }
|
|
85
|
+
.ps-pill .ps-pick:disabled { opacity:.6; cursor:default; }
|
|
86
|
+
.ps-switch .ps-text { width:min(420px, 100%); resize:none; border-radius:12px; border:1px solid #fff3; background:#000d; color:#fff;
|
|
87
|
+
padding:7px 10px; font:400 14px -apple-system, system-ui, sans-serif; -webkit-backdrop-filter:blur(12px); backdrop-filter:blur(12px); }
|
|
88
|
+
.ps-switch .ps-text::placeholder { color:#fffa; }
|
|
89
|
+
.ps-switch .ps-text[hidden] { display:none; }
|
|
90
|
+
.ps-picked { margin-left:2px; padding:7px 11px; border-radius:999px; color:#fff; background:#1f9d55; white-space:nowrap; }
|
|
91
|
+
.ps-note { background:#000d; color:#fff; border-radius:8px; padding:5px 10px; font:500 12px -apple-system, system-ui, sans-serif; text-align:center; }
|
|
92
|
+
.ps-note[hidden] { display:none; }
|
|
93
|
+
.ps-embed header.top { display:none; }
|
|
94
|
+
.ps-embed main { padding-bottom:12px !important; }`;
|
|
95
|
+
|
|
96
|
+
const typing = (el) => Boolean(el?.closest?.('input, select, textarea, [contenteditable]:not([contenteditable="false"])'));
|
|
97
|
+
|
|
98
|
+
function mount({ takes, initial, render, above = null }) {
|
|
99
|
+
const keys = Object.keys(takes);
|
|
100
|
+
const params = new URLSearchParams(location.search);
|
|
101
|
+
const qid = params.get('q');
|
|
102
|
+
const site = globalThis.ClaudeSite;
|
|
103
|
+
if (!document.getElementById('ps-style')) document.head.insertAdjacentHTML('beforeend', `<style id="ps-style">${CSS}</style>`);
|
|
104
|
+
if (params.has('take')) {
|
|
105
|
+
let only = startTake(keys, params.get('take'), initial);
|
|
106
|
+
document.documentElement.classList.add('ps-embed');
|
|
107
|
+
render(only);
|
|
108
|
+
const embedded = {
|
|
109
|
+
takes: keys,
|
|
110
|
+
get take() { return only; },
|
|
111
|
+
go(next) {
|
|
112
|
+
if (!keys.includes(next) || next === only) return;
|
|
113
|
+
only = next;
|
|
114
|
+
const u = new URL(location.href); u.searchParams.set('take', only); history.replaceState(history.state, '', u);
|
|
115
|
+
render(only);
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
return (globalThis.PrototypeSwitcher.current = embedded);
|
|
119
|
+
}
|
|
120
|
+
let take = startTake(keys, params.get('variant'), initial);
|
|
121
|
+
let question = null;
|
|
122
|
+
let topics = [];
|
|
123
|
+
// A push opened this page (board/store.mjs pushUrl). Read once and dropped from the URL, so a pick made here or a
|
|
124
|
+
// reload stays on the page; only the first load sends an already-picked question on to its card's thread.
|
|
125
|
+
let fromPush = params.get('from') === 'push';
|
|
126
|
+
if (fromPush) { const u = new URL(location.href); u.searchParams.delete('from'); history.replaceState(null, '', u); }
|
|
127
|
+
|
|
128
|
+
const box = document.createElement('div');
|
|
129
|
+
box.className = 'ps-switch';
|
|
130
|
+
box.innerHTML = '<div class="ps-note" role="status" hidden></div><textarea class="ps-text" rows="2" placeholder="Note with your pick (optional)" aria-label="Note with your pick" hidden></textarea><div class="ps-pill"></div>';
|
|
131
|
+
if (above) box.prepend(above);
|
|
132
|
+
document.body.append(box);
|
|
133
|
+
const pill = box.querySelector('.ps-pill');
|
|
134
|
+
const noteEl = box.querySelector('.ps-note');
|
|
135
|
+
const text = box.querySelector('.ps-text');
|
|
136
|
+
let busy = false;
|
|
137
|
+
|
|
138
|
+
const note = (text) => { noteEl.textContent = text || ''; noteEl.hidden = !text; };
|
|
139
|
+
function paint() {
|
|
140
|
+
const state = pickState(question, take);
|
|
141
|
+
pill.innerHTML = pillHtml({ name: takes[take], pick: state, busy });
|
|
142
|
+
text.hidden = !(state.kind === 'pick' || state.kind === 'change');
|
|
143
|
+
}
|
|
144
|
+
function go(next) {
|
|
145
|
+
take = next;
|
|
146
|
+
const u = new URL(location.href); u.searchParams.set('variant', take); history.replaceState(null, '', u);
|
|
147
|
+
note('');
|
|
148
|
+
paint();
|
|
149
|
+
render(take);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function load() {
|
|
153
|
+
if (!qid || !site) return;
|
|
154
|
+
let board = null;
|
|
155
|
+
try {
|
|
156
|
+
board = await (await fetch(site.url('api/board'), { cache: 'no-store' })).json();
|
|
157
|
+
question = (board.owner || []).find((q) => q.id === qid) || null;
|
|
158
|
+
topics = board.ownerTopics || [];
|
|
159
|
+
if (!question) note('This prototype’s question is gone, so there is nothing to pick.');
|
|
160
|
+
} catch { /* keep the last question shown */ }
|
|
161
|
+
if (fromPush && pickedChoice(question)) {
|
|
162
|
+
const card = /^item:(.+)$/.exec(question.topic || '')?.[1];
|
|
163
|
+
replaceWith(site.url(card ? `#item=${encodeURIComponent(card)}&reply` : 'questions.html'));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (question && !text.value) text.value = readDraft(qid).note || '';
|
|
167
|
+
fromPush = false;
|
|
168
|
+
paint();
|
|
169
|
+
return board;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function pick() {
|
|
173
|
+
const state = pickState(question, take);
|
|
174
|
+
if (busy || !(state.kind === 'pick' || state.kind === 'change')) return;
|
|
175
|
+
busy = true; paint();
|
|
176
|
+
const answered = question;
|
|
177
|
+
let ui, pills, name;
|
|
178
|
+
try {
|
|
179
|
+
ui = await sharedQuestions(site);
|
|
180
|
+
pills = ui.topicRow(topics, answered.topic).pills;
|
|
181
|
+
const topic = topics.find((t) => t.id === answered.topic);
|
|
182
|
+
name = topic ? ui.topicName(topic) : '';
|
|
183
|
+
await site.send('owner-answer', { id: answered.id, question: answered.question, choice: state.option.id, label: answerLabel(state.option), note: text.value.trim() });
|
|
184
|
+
} catch { busy = false; paint(); note('Could not send. Is the host awake?'); return; }
|
|
185
|
+
try { localStorage.removeItem(draftKey(qid)); } catch { /* nothing kept */ }
|
|
186
|
+
text.value = '';
|
|
187
|
+
busy = false;
|
|
188
|
+
note('Sent. Claude replies on the card.');
|
|
189
|
+
const board = await load();
|
|
190
|
+
const next = ui.nextQuestion(board?.owner || [], pills, answered);
|
|
191
|
+
if (next) {
|
|
192
|
+
const u = new URL(site.url(questionUrl(next)), location.href);
|
|
193
|
+
if (globalThis.PageBack?.pushed) u.searchParams.set('from', 'push');
|
|
194
|
+
replaceWith(u);
|
|
195
|
+
} else {
|
|
196
|
+
const route = new URL(site.url(''), location.href).pathname.replace(/^\/+|\/+$/g, '');
|
|
197
|
+
ui.sentNotice.put(route, `Sent 1 answer${name ? ` to ${name}` : ''}.`);
|
|
198
|
+
replaceWith(site.url('./'));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
text.addEventListener('keydown', (e) => {
|
|
202
|
+
if (e.key !== 'Enter' || !(e.metaKey || e.ctrlKey) || e.isComposing) return;
|
|
203
|
+
e.preventDefault();
|
|
204
|
+
pick();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
pill.addEventListener('click', (e) => {
|
|
208
|
+
const act = e.target.closest('[data-ps]')?.dataset.ps;
|
|
209
|
+
if (act === 'prev') go(takeAt(keys, take, -1));
|
|
210
|
+
else if (act === 'next') go(takeAt(keys, take, 1));
|
|
211
|
+
else if (act === 'pick') pick();
|
|
212
|
+
});
|
|
213
|
+
document.addEventListener('keydown', (e) => {
|
|
214
|
+
if (typing(e.target) || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
215
|
+
if (e.key === 'ArrowLeft') go(takeAt(keys, take, -1));
|
|
216
|
+
else if (e.key === 'ArrowRight') go(takeAt(keys, take, 1));
|
|
217
|
+
});
|
|
218
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); });
|
|
219
|
+
|
|
220
|
+
go(take);
|
|
221
|
+
load();
|
|
222
|
+
return (globalThis.PrototypeSwitcher.current = { takes: keys, get take() { return take; }, go });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
globalThis.PrototypeSwitcher = { takeAt, startTake, pickedChoice, pickState, answerLabel, pillHtml, mount };
|
|
226
|
+
})();
|
|
@@ -0,0 +1,55 @@
|
|
|
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, viewport-fit=cover">
|
|
6
|
+
<title>{{prototypeTitle}}</title>
|
|
7
|
+
<link rel="stylesheet" href="colors.css">
|
|
8
|
+
<link rel="stylesheet" href="style.css">
|
|
9
|
+
<link rel="stylesheet" href="hub.css">
|
|
10
|
+
<link rel="stylesheet" href="sidebar.css">
|
|
11
|
+
<style>
|
|
12
|
+
/* The one page every recorded prototype is (board/server.mjs fills the {{…}} slots; prototype-kit.js documents what a
|
|
13
|
+
take gets). main keeps room below the stage for the take pill and its note. */
|
|
14
|
+
main.proto-main { max-width: none; padding-left: 0; padding-right: 0; padding-bottom: calc(150px + var(--app-bottom, 0px) + env(safe-area-inset-bottom)); }
|
|
15
|
+
/* kit.hub() is a whole page with its own gutter and header, so it goes edge to edge. */
|
|
16
|
+
main.proto-main:has(> #stage > .kit-hub) { padding-top: 0; }
|
|
17
|
+
/* contain and the transform make the stage the containing block of everything in it, position: fixed included, and
|
|
18
|
+
clip its paint, so a take can't cover the header, the pill or the app shell around the page. */
|
|
19
|
+
#stage { position: relative; max-width: 1180px; min-width: 0; margin: 0 auto; contain: layout paint; transform: translateZ(0); isolation: isolate; overflow-x: clip; padding: 0 16px; }
|
|
20
|
+
#stage:has(> .kit-hub) { padding: 0; }
|
|
21
|
+
#stage .top { position: static; }
|
|
22
|
+
#stage .hub-view main { padding-bottom: 16px; }
|
|
23
|
+
.kit-missing { margin: 0; padding: 16px; border: 1px dashed var(--line); border-radius: 12px; color: var(--muted); overflow-wrap: anywhere; }
|
|
24
|
+
#protoToast { position: fixed; z-index: 40; left: 50%; top: calc(64px + env(safe-area-inset-top)); transform: translateX(-50%); max-width: calc(100vw - 32px); }
|
|
25
|
+
#protoToast[hidden] { display: none; }
|
|
26
|
+
@media (max-width: 899px) { #protoToast { top: max(12px, env(safe-area-inset-top)); } }
|
|
27
|
+
|
|
28
|
+
#stage .kit-sidebar.sidebar { position: relative; inset: auto; z-index: auto; transform: none; visibility: visible; transition: none;
|
|
29
|
+
width: min(320px, 100%); height: 640px; margin: 0 auto; padding: 0 8px; border: 1px solid var(--line); border-radius: 14px; }
|
|
30
|
+
#stage .kit-sidebar.sidebar:not(.kit-head) > header.top { display: none; }
|
|
31
|
+
@media (min-width: 900px) { #stage .kit-sidebar.sidebar { width: 272px; } }
|
|
32
|
+
#stage .kit-sheet-strip { padding: 32px 0 0; border-radius: 12px; background: rgba(0,0,0,.35); overflow: hidden; }
|
|
33
|
+
#stage .kit-sheet { max-width: 560px; margin: 0 auto; border: 1px solid var(--line); border-bottom: 0; border-radius: 16px 16px 0 0; background: var(--panel); color: var(--text); box-shadow: 0 -8px 30px rgba(0,0,0,.18); }
|
|
34
|
+
#stage .kit-sheet .sheet-head { padding: 10px 16px 0; }
|
|
35
|
+
#stage .kit-sheet-body { padding: 6px 16px 16px; }
|
|
36
|
+
</style>
|
|
37
|
+
</head>
|
|
38
|
+
<body>
|
|
39
|
+
{{prototypeHeader}}
|
|
40
|
+
<main class="proto-main"><div id="stage"></div></main>
|
|
41
|
+
<div class="toast" id="protoToast" role="status" hidden></div>
|
|
42
|
+
<script id="prototypeData" type="application/json">{{prototypeData}}</script>
|
|
43
|
+
<script src="claude.js"></script>
|
|
44
|
+
<script src="ui.js"></script>
|
|
45
|
+
<script src="ui-links.js"></script>
|
|
46
|
+
<script src="ui-enums.js"></script>
|
|
47
|
+
<script src="ui-cards.js"></script>
|
|
48
|
+
<script src="ui-page-header.js"></script>
|
|
49
|
+
<script src="ui-sidebar.js"></script>
|
|
50
|
+
<script src="ui-hub.js"></script>
|
|
51
|
+
<script src="prototype-switcher.js"></script>
|
|
52
|
+
<script src="prototype-kit.js"></script>
|
|
53
|
+
<script>PrototypeKit.start(JSON.parse(document.getElementById('prototypeData').textContent));</script>
|
|
54
|
+
</body>
|
|
55
|
+
</html>
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// The app shell's router (docs/adr/0055-one-app-for-the-whole-hub.md): every URL on the hub names a view, and a view
|
|
2
|
+
// names its URL, so a push link, a reload and a shared link all land on the same view. viewOf and urlOf are pure;
|
|
3
|
+
// ui.test.mjs runs them in node:vm. listen() catches clicks on links to a view the shell mounts, and back and forward.
|
|
4
|
+
// A view is {kind, …}:
|
|
5
|
+
// start `/`, which the shell turns into the last board
|
|
6
|
+
// board {route, hash} /<route>/, hash such as #item=<key>&reply
|
|
7
|
+
// settings {route} /<route>/settings.html, a request to open the modal over that board
|
|
8
|
+
// questions {route, id} /<route>/questions.html#<id>
|
|
9
|
+
// notes {route, item | wave} /<route>/notes.html?item=<key> or ?wave=<lineup slug>, and plan.html, which the board
|
|
10
|
+
// redirects there
|
|
11
|
+
// hub {hash, w} /_hub/ with a settings modal named by hash; legacy /_hub/settings and
|
|
12
|
+
// /_hub/settings/polling?w=<HH:MM> open the same modals (ADR 0075)
|
|
13
|
+
// frame {route, url} any other page under a route, a folder or an .html file: other sites, prototypes and
|
|
14
|
+
// every other board page, shown in a frame
|
|
15
|
+
// Any other URL, such as an image or another origin, is no view (null) and loads as itself.
|
|
16
|
+
// A push's from=push mark (entryOf) is no part of the view, so a push link reads as the view its page shows.
|
|
17
|
+
globalThis.AppRouter = (function () {
|
|
18
|
+
// The kinds the shell mounts in place; a link to any other kind loads its page.
|
|
19
|
+
const MOUNTED = new Set(['board', 'settings', 'questions', 'notes', 'hub', 'frame']);
|
|
20
|
+
// A page's path under its route. hub/shell.mjs pageOf takes the same rule, so the hub answers a page load of every view
|
|
21
|
+
// this mounts with the shell; hub/shell.test.mjs checks the two agree.
|
|
22
|
+
const isPagePath = (rest) => rest === '' || rest.endsWith('/') || /\.html?$/.test(rest);
|
|
23
|
+
|
|
24
|
+
// href against base; boards, the set of board routes. null for another origin.
|
|
25
|
+
function viewOf(href, { boards = new Set(), base = globalThis.location?.href } = {}) {
|
|
26
|
+
let u;
|
|
27
|
+
try { u = new URL(href, base); } catch { return null; }
|
|
28
|
+
if (base && u.origin !== new URL(base).origin) return null;
|
|
29
|
+
const p = u.pathname;
|
|
30
|
+
if (p === '/') return { kind: 'start' };
|
|
31
|
+
if (['/_hub/', '/_hub/settings', '/_hub/settings/polling'].includes(p)) {
|
|
32
|
+
const hash = p === '/_hub/settings/polling' ? '#polling' : u.hash || (p === '/_hub/settings' ? '#notifications' : '');
|
|
33
|
+
return { kind: 'hub', ...(hash ? { hash } : {}), ...(hash === '#polling' && u.searchParams.get('w') ? { w: u.searchParams.get('w') } : {}) };
|
|
34
|
+
}
|
|
35
|
+
const m = /^\/([^/]+)\/(.*)$/.exec(p);
|
|
36
|
+
let route = null;
|
|
37
|
+
try { route = m && decodeURIComponent(m[1]); } catch { return null; }
|
|
38
|
+
if (!route || route === '_hub' || !isPagePath(m[2])) return null;
|
|
39
|
+
const frame = { kind: 'frame', route, url: p + u.search + u.hash };
|
|
40
|
+
if (!boards.has(route)) return frame;
|
|
41
|
+
const page = m[2];
|
|
42
|
+
if (page === '') return { kind: 'board', route, hash: u.hash };
|
|
43
|
+
if (page === 'settings.html') return { kind: 'settings', route };
|
|
44
|
+
if (page === 'questions.html') {
|
|
45
|
+
let id = null;
|
|
46
|
+
try { id = u.hash.length > 1 ? decodeURIComponent(u.hash.slice(1)) : null; } catch { id = u.hash.slice(1); }
|
|
47
|
+
return { kind: 'questions', route, id };
|
|
48
|
+
}
|
|
49
|
+
if (page === 'notes.html' || page === 'plan.html') {
|
|
50
|
+
return u.searchParams.has('wave') ? { kind: 'notes', route, wave: u.searchParams.get('wave') } : { kind: 'notes', route, item: u.searchParams.get('item') };
|
|
51
|
+
}
|
|
52
|
+
return frame;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function urlOf(view) {
|
|
56
|
+
const r = view.route !== undefined ? `/${encodeURIComponent(view.route)}/` : '';
|
|
57
|
+
switch (view.kind) {
|
|
58
|
+
case 'start': return '/';
|
|
59
|
+
case 'board': return r + (view.hash || '');
|
|
60
|
+
case 'settings': return `${r}settings.html`;
|
|
61
|
+
case 'questions': return `${r}questions.html${view.id ? `#${encodeURIComponent(view.id)}` : ''}`;
|
|
62
|
+
case 'notes': return `${r}notes.html${view.wave ? `?wave=${encodeURIComponent(view.wave)}` : view.item ? `?item=${encodeURIComponent(view.item)}` : ''}`;
|
|
63
|
+
case 'hub': return `/_hub/${view.w ? `?w=${encodeURIComponent(view.w)}` : ''}${view.hash || ''}`;
|
|
64
|
+
default: return view.url;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const mounted = (view) => Boolean(view && MOUNTED.has(view.kind));
|
|
69
|
+
|
|
70
|
+
// A push to a page other than the board carries from=push (board/store.mjs pushUrl). The router keeps that mark in
|
|
71
|
+
// the entry's history.state as pushed, which a reload keeps too, and drops it from the URL.
|
|
72
|
+
// → {href without from=push, pushed}
|
|
73
|
+
function entryOf(href) {
|
|
74
|
+
const u = new URL(href, globalThis.location?.href);
|
|
75
|
+
const pushed = u.searchParams.get('from') === 'push';
|
|
76
|
+
if (pushed) u.searchParams.delete('from');
|
|
77
|
+
return { href: u.href, pushed };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The page tree (docs/adr/0058-back-follows-the-page-tree.md): a board, its Waiting on you, and a prototype page tied
|
|
81
|
+
// to one of its questions by ?q=, framed. levelOf → 0, 1 or 2 down the tree, or null for a view outside it.
|
|
82
|
+
function questionOf(view, boards) {
|
|
83
|
+
if (view?.kind !== 'frame' || !boards.has(view.route)) return null;
|
|
84
|
+
const u = new URL(view.url, 'http://hub');
|
|
85
|
+
return /^\/[^/]+\/prototype-[\w-]+\.html$/.test(u.pathname) ? u.searchParams.get('q') || null : null;
|
|
86
|
+
}
|
|
87
|
+
function levelOf(view, { boards = new Set() } = {}) {
|
|
88
|
+
if (view?.kind === 'board') return 0;
|
|
89
|
+
if (view?.kind === 'questions') return 1;
|
|
90
|
+
return questionOf(view, boards) ? 2 : null;
|
|
91
|
+
}
|
|
92
|
+
// → the views above view in the tree, its board first. card: the card whose thread the owner left the board from,
|
|
93
|
+
// which the board keeps open.
|
|
94
|
+
function parentsOf(view, { boards = new Set(), card = null } = {}) {
|
|
95
|
+
const q = questionOf(view, boards);
|
|
96
|
+
if (!q && view?.kind !== 'questions') return [];
|
|
97
|
+
const board = { kind: 'board', route: view.route, hash: card ? `#item=${encodeURIComponent(card)}` : '' };
|
|
98
|
+
return q ? [board, { kind: 'questions', route: view.route, id: q }] : [board];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Each history entry carries {idx}, its distance from the shell's first entry, pushed when a push opened it, and on a
|
|
102
|
+
// view in the page tree up, how many of the entries under it are its parents. Every way into the tree writes the
|
|
103
|
+
// parents it lacks under it, so a back arrow there (up), the browser's back and a swipe all go up one level. Any other
|
|
104
|
+
// back arrow (a.back) goes one step back by idx and pushed (ui-page-header.js backStep), else follows its href.
|
|
105
|
+
// go(view, href, how, from) shows a view: how is 'push' for a link, a push tap or the start, 'pop' for back and
|
|
106
|
+
// forward; from.card is the open card a link was tapped in. It answers false, or a promise of false, when the view
|
|
107
|
+
// stays, such as when the owner keeps an unsaved edit, and a refused pop then returns to the entry it left.
|
|
108
|
+
// boards() → the board routes now.
|
|
109
|
+
// → {push(href, from), replace(href), up()}: push and replace write an entry for a view go is showing; replace answers
|
|
110
|
+
// 'up' when it went back to a parent instead, which then takes href, and go shows it on that pop.
|
|
111
|
+
function listen({ boards, go }) {
|
|
112
|
+
const b = () => ({ boards: boards() });
|
|
113
|
+
const mark = (pushed) => (pushed ? { pushed: true } : {});
|
|
114
|
+
let idx = Number.isInteger(history.state?.idx) ? history.state.idx : 0;
|
|
115
|
+
let landing = null;
|
|
116
|
+
const put = (how, href, state) => {
|
|
117
|
+
if (how === 'push') idx++;
|
|
118
|
+
history[how === 'push' ? 'pushState' : 'replaceState']({ idx, ...state }, '', href);
|
|
119
|
+
};
|
|
120
|
+
// Pushes parents, then href. up: the current entry's own up when the first parent sits on it, else -1, so a board
|
|
121
|
+
// pushed first carries none.
|
|
122
|
+
const climb = (parents, href, pushed, up) => {
|
|
123
|
+
for (const p of parents) put('push', urlOf(p), up++ >= 0 ? { up } : {});
|
|
124
|
+
put('push', href, { ...(up >= 0 ? { up: up + 1 } : {}), ...mark(pushed) });
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const first = entryOf(location.href);
|
|
128
|
+
const start = viewOf(first.href, b());
|
|
129
|
+
const depth = levelOf(start, b());
|
|
130
|
+
if (depth && !(history.state?.up >= depth)) {
|
|
131
|
+
const parents = parentsOf(start, b());
|
|
132
|
+
put('replace', urlOf(parents[0]), {});
|
|
133
|
+
climb(parents.slice(1), first.href, first.pushed, 0);
|
|
134
|
+
} else history.replaceState({ ...(history.state || {}), idx, ...mark(first.pushed) }, '', first.href);
|
|
135
|
+
|
|
136
|
+
function write(how, href, { card = null } = {}) {
|
|
137
|
+
const { href: url, pushed } = entryOf(href);
|
|
138
|
+
if (how === 'push' && url === location.href) return;
|
|
139
|
+
const view = viewOf(url, b());
|
|
140
|
+
const here = viewOf(location.href, b());
|
|
141
|
+
const level = levelOf(view, b());
|
|
142
|
+
const at = here && view && here.route === view.route ? levelOf(here, b()) : null;
|
|
143
|
+
const below = history.state?.up || 0;
|
|
144
|
+
const keep = level !== null && level === at && below ? { up: below } : {};
|
|
145
|
+
if (how === 'replace') {
|
|
146
|
+
if (level !== null && at !== null && level < at && at - level <= below) {
|
|
147
|
+
landing = href;
|
|
148
|
+
history.go(level - at);
|
|
149
|
+
return 'up';
|
|
150
|
+
}
|
|
151
|
+
put('replace', url, { ...keep, ...mark(pushed) });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// Another question on Waiting on you takes this one's place, so back still goes to the board.
|
|
155
|
+
if (level === 1 && at === 1) { put('replace', url, { ...keep, ...mark(pushed) }); return; }
|
|
156
|
+
const parents = parentsOf(view, { ...b(), card });
|
|
157
|
+
if (level !== null && at !== null && at < level) {
|
|
158
|
+
history.replaceState(history.state, '', urlOf(parents[at]));
|
|
159
|
+
climb(parents.slice(at + 1), url, pushed, below);
|
|
160
|
+
} else climb(parents, url, pushed, -1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A back arrow's tap on a view in the tree: back when its parent is the entry under it, else on to the parent.
|
|
164
|
+
// → false for a view outside the tree.
|
|
165
|
+
function up() {
|
|
166
|
+
const parents = parentsOf(viewOf(location.href, b()), b());
|
|
167
|
+
if (!parents.length) return false;
|
|
168
|
+
if (history.state?.up > 0) history.back();
|
|
169
|
+
else go(parents.at(-1), new URL(urlOf(parents.at(-1)), location.href).href, 'push');
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Listens after sidebar.js, so a tap the drawer took (its logo, ✕) is already defaultPrevented.
|
|
174
|
+
document.addEventListener('click', (e) => {
|
|
175
|
+
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
176
|
+
const a = e.target.closest?.('a[href]');
|
|
177
|
+
if (!a || (a.target && a.target !== '_self') || a.hasAttribute('download')) return;
|
|
178
|
+
if (a.classList.contains('back')) {
|
|
179
|
+
if (up()) { e.preventDefault(); return; }
|
|
180
|
+
if (BoardUI.backStep({ pushed: history.state?.pushed === true, href: location.href, idx: history.state?.idx }) === 'back') {
|
|
181
|
+
e.preventDefault();
|
|
182
|
+
history.back();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const url = new URL(a.href, location.href);
|
|
187
|
+
const view = viewOf(url.href, { boards: boards() });
|
|
188
|
+
if (!mounted(view)) return;
|
|
189
|
+
// A link to another card on this board, or another question on Waiting on you, only moves the hash, which the
|
|
190
|
+
// view follows itself. On Waiting on you it takes this entry's place, so back still goes to the board.
|
|
191
|
+
if (url.pathname === location.pathname && url.search === location.search && url.hash && url.hash !== location.hash) {
|
|
192
|
+
if (view.kind === 'hub') { e.preventDefault(); go(view, url.href, 'push'); return; }
|
|
193
|
+
if (view.kind !== 'questions') return;
|
|
194
|
+
e.preventDefault();
|
|
195
|
+
write('replace', url.href);
|
|
196
|
+
dispatchEvent(new HashChangeEvent('hashchange'));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
e.preventDefault();
|
|
200
|
+
go(view, url.href, 'push', { card: a.closest?.('details.item[open][data-key]')?.dataset.key || null });
|
|
201
|
+
});
|
|
202
|
+
let restoring = false;
|
|
203
|
+
addEventListener('popstate', (e) => {
|
|
204
|
+
const was = idx;
|
|
205
|
+
if (Number.isInteger(e.state?.idx)) idx = e.state.idx;
|
|
206
|
+
if (landing) {
|
|
207
|
+
const to = entryOf(landing);
|
|
208
|
+
landing = null;
|
|
209
|
+
history.replaceState({ ...(e.state || {}), ...mark(to.pushed) }, '', to.href);
|
|
210
|
+
}
|
|
211
|
+
if (restoring) { restoring = false; return; }
|
|
212
|
+
const view = viewOf(location.href, { boards: boards() });
|
|
213
|
+
if (!mounted(view)) { location.reload(); return; }
|
|
214
|
+
Promise.resolve(go(view, location.href, 'pop')).then((shown) => {
|
|
215
|
+
if (shown !== false || was === idx) return;
|
|
216
|
+
restoring = true;
|
|
217
|
+
history.go(was - idx);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
// A hash link adds an entry with no state of its own.
|
|
221
|
+
addEventListener('hashchange', () => { if (!Number.isInteger(history.state?.idx)) history.replaceState({ idx: ++idx }, ''); });
|
|
222
|
+
return { push: (href, from) => write('push', href, from), replace: (href) => write('replace', href), up };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return { viewOf, urlOf, mounted, entryOf, listen };
|
|
226
|
+
})();
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// The rules editor on Board Settings and on the Hub page
|
|
2
|
+
// (docs/adr/0039-rules-live-on-settings-at-hub-and-board-level.md). Edits stay on the page until Save, as the
|
|
3
|
+
// reviewers list does; Save hands {rules: [{id?, text}], base: [the ids it loaded]} to the page's save(), whose
|
|
4
|
+
// server merges it (hub/rules.mjs applyRules). It brings its own CSS, colours from colors.css only, and takes the page's
|
|
5
|
+
// button classes. rows, payload, dirty and render are pure, for ui.test.mjs; mount touches the DOM.
|
|
6
|
+
(function () {
|
|
7
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
8
|
+
const one = (t) => String(t ?? '').replace(/\s+/g, ' ').trim();
|
|
9
|
+
|
|
10
|
+
// A row is a saved rule (orig is its saved text) or a new one (orig null); gone marks a staged removal.
|
|
11
|
+
const rows = (rules) => (rules || []).map((r) => ({ key: r.id, id: r.id, text: r.text, by: r.by, orig: r.text, gone: false }));
|
|
12
|
+
const payload = (list) => ({
|
|
13
|
+
rules: list.filter((r) => !r.gone).map((r) => (r.id ? { id: r.id, text: r.text } : { text: r.text })),
|
|
14
|
+
base: list.filter((r) => r.id).map((r) => r.id),
|
|
15
|
+
});
|
|
16
|
+
const dirty = (list) => list.some((r) => r.gone || r.orig === null || r.text !== r.orig);
|
|
17
|
+
|
|
18
|
+
// editing: the key of the row open for editing. buttons: the page's {primary, quiet, icon} classes; icon is ✕'s.
|
|
19
|
+
function render(list, { readOnly = false, editing = null, buttons = {}, empty = 'No rules yet.' } = {}) {
|
|
20
|
+
if (!list.length) return `<p class="muted small rule-empty">${esc(empty)}</p>`;
|
|
21
|
+
const quiet = esc(buttons.quiet || '');
|
|
22
|
+
return list.map((r) => {
|
|
23
|
+
if (!readOnly && r.key === editing) {
|
|
24
|
+
return `<div class="rule editing" data-key="${esc(r.key)}"><textarea class="rule-input" rows="3" aria-label="Rule text">${esc(r.text)}</textarea>
|
|
25
|
+
<div class="rule-edit-actions"><button type="button" class="${quiet}" data-act="cancel">Cancel</button><button type="button" class="${esc(buttons.primary || '')}" data-act="done">Done</button></div></div>`;
|
|
26
|
+
}
|
|
27
|
+
const state = r.gone ? 'gone' : r.orig === null ? 'new' : r.text !== r.orig ? 'edited' : '';
|
|
28
|
+
const meta = [r.by === 'claude' ? '<span class="rule-by">Added by Claude</span>' : '', state && !readOnly ? `<span>${state === 'gone' ? 'removed' : state}</span>` : ''].join('');
|
|
29
|
+
const actions = readOnly ? '' : `<div class="rule-actions">${r.gone ? '' : `<button type="button" class="${quiet}" data-act="edit" aria-label="Edit this rule">Edit</button>`}
|
|
30
|
+
<button type="button" class="${esc(buttons.icon ?? buttons.quiet ?? '')}" data-act="${r.gone ? 'undo' : 'remove'}" aria-label="${r.gone ? 'Keep' : 'Remove'} this rule">${r.gone ? 'Undo' : '✕'}</button></div>`;
|
|
31
|
+
return `<div class="rule${state ? ` ${state}` : ''}" data-key="${esc(r.key)}"><div class="rule-body"><div class="rule-text">${esc(r.text)}</div>${meta ? `<div class="rule-meta">${meta}</div>` : ''}</div>${actions}</div>`;
|
|
32
|
+
}).join('');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const CSS = `.rules-editor .rule { display:flex; align-items:flex-start; gap:8px; padding:9px 10px; margin:6px 0; border:1px solid var(--line); border-radius:9px; background:var(--panel); }
|
|
36
|
+
.rules-editor .rule.new { border-color:var(--accent); }
|
|
37
|
+
.rules-editor .rule.gone .rule-text { opacity:.5; text-decoration:line-through; }
|
|
38
|
+
.rules-editor .rule.editing { flex-direction:column; align-items:stretch; }
|
|
39
|
+
.rules-editor .rule-body { flex:1; min-width:0; }
|
|
40
|
+
.rules-editor .rule-text { overflow-wrap:anywhere; }
|
|
41
|
+
.rules-editor .rule-meta { display:flex; flex-wrap:wrap; gap:6px; margin-top:4px; font-size:12px; color:var(--muted); }
|
|
42
|
+
.rules-editor .rule-by { padding:0 6px; border-radius:6px; background:var(--chip); color:var(--muted); }
|
|
43
|
+
.rules-editor .rule-actions { flex:none; display:flex; gap:4px; }
|
|
44
|
+
.rules-editor textarea { box-sizing:border-box; width:100%; min-width:0; resize:vertical; font:inherit; font-size:16px; color:var(--text); background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:6px 8px; }
|
|
45
|
+
.rules-editor .rule-add { display:flex; align-items:flex-end; gap:6px; margin-top:8px; }
|
|
46
|
+
.rules-editor .rule-add textarea { flex:1; }
|
|
47
|
+
.rules-editor .rule-edit-actions, .rules-editor .rule-save { display:flex; align-items:center; justify-content:flex-end; gap:8px; margin-top:8px; }
|
|
48
|
+
.rules-editor .rule-save .rule-status { flex:1; min-width:0; font-size:12px; color:var(--muted); }`;
|
|
49
|
+
|
|
50
|
+
// opts: {rules, readOnly, buttons, empty, placeholder, save(payload) → Promise<{rules, message}>}. Sets data-dirty on
|
|
51
|
+
// el while anything is unsaved, so a page that reloads itself can wait.
|
|
52
|
+
function mount(el, opts) {
|
|
53
|
+
if (!document.getElementById('rules-editor-css')) document.head.insertAdjacentHTML('beforeend', `<style id="rules-editor-css">${CSS}</style>`);
|
|
54
|
+
const b = opts.buttons || {};
|
|
55
|
+
let list = rows(opts.rules);
|
|
56
|
+
let editing = null;
|
|
57
|
+
let added = 0;
|
|
58
|
+
el.classList.add('rules-editor');
|
|
59
|
+
el.innerHTML = `<div class="rule-list"></div>${opts.readOnly ? '' : `<form class="rule-add"><textarea rows="2" placeholder="${esc(opts.placeholder || 'Add a rule')}" aria-label="New rule"></textarea><button type="submit" class="${esc(b.quiet || '')}">Add</button></form>
|
|
60
|
+
<div class="rule-save"><span class="rule-status" role="status"></span><button type="button" class="${esc(b.primary || '')}" data-act="save" disabled>Save</button></div>`}`;
|
|
61
|
+
const listEl = el.querySelector('.rule-list');
|
|
62
|
+
const addBox = el.querySelector('.rule-add textarea');
|
|
63
|
+
const saveBtn = el.querySelector('[data-act="save"]');
|
|
64
|
+
const status = el.querySelector('.rule-status');
|
|
65
|
+
function mark() {
|
|
66
|
+
const unsaved = dirty(list);
|
|
67
|
+
saveBtn.disabled = !unsaved || editing !== null;
|
|
68
|
+
if (unsaved || editing !== null || addBox.value.trim()) el.dataset.dirty = ''; else delete el.dataset.dirty;
|
|
69
|
+
}
|
|
70
|
+
function draw() {
|
|
71
|
+
listEl.innerHTML = render(list, { readOnly: opts.readOnly, editing, buttons: b, empty: opts.empty });
|
|
72
|
+
if (opts.readOnly) return;
|
|
73
|
+
mark();
|
|
74
|
+
const input = listEl.querySelector('.rule-input');
|
|
75
|
+
if (input) { input.focus(); input.setSelectionRange(input.value.length, input.value.length); }
|
|
76
|
+
}
|
|
77
|
+
draw();
|
|
78
|
+
if (opts.readOnly) return { set(rules) { list = rows(rules); draw(); } };
|
|
79
|
+
listEl.addEventListener('keydown', (ev) => {
|
|
80
|
+
if (ev.key === 'Enter' && !ev.shiftKey && !ev.isComposing && ev.target.matches('.rule-input')) { ev.preventDefault(); listEl.querySelector('[data-act="done"]').click(); }
|
|
81
|
+
});
|
|
82
|
+
listEl.addEventListener('click', (ev) => {
|
|
83
|
+
const btn = ev.target.closest('button[data-act]');
|
|
84
|
+
const row = btn && list.find((r) => r.key === btn.closest('.rule').dataset.key);
|
|
85
|
+
if (!row) return;
|
|
86
|
+
const act = btn.dataset.act;
|
|
87
|
+
if (act === 'edit') editing = row.key;
|
|
88
|
+
else if (act === 'cancel') editing = null;
|
|
89
|
+
else if (act === 'done') {
|
|
90
|
+
const text = one(listEl.querySelector('.rule-input').value);
|
|
91
|
+
if (!text) return;
|
|
92
|
+
row.text = text;
|
|
93
|
+
editing = null;
|
|
94
|
+
} else if (act === 'remove') {
|
|
95
|
+
if (row.orig === null) list = list.filter((r) => r !== row); else row.gone = true;
|
|
96
|
+
} else if (act === 'undo') row.gone = false;
|
|
97
|
+
status.textContent = '';
|
|
98
|
+
draw();
|
|
99
|
+
});
|
|
100
|
+
const add = () => {
|
|
101
|
+
const text = one(addBox.value);
|
|
102
|
+
if (!text) return;
|
|
103
|
+
list.push({ key: `new-${++added}`, id: null, text, by: 'owner', orig: null, gone: false });
|
|
104
|
+
addBox.value = '';
|
|
105
|
+
status.textContent = '';
|
|
106
|
+
draw();
|
|
107
|
+
};
|
|
108
|
+
el.querySelector('.rule-add').addEventListener('submit', (ev) => { ev.preventDefault(); add(); });
|
|
109
|
+
// A rule is one line, so Enter adds it.
|
|
110
|
+
addBox.addEventListener('keydown', (ev) => { if (ev.key === 'Enter' && !ev.shiftKey && !ev.isComposing) { ev.preventDefault(); add(); } });
|
|
111
|
+
addBox.addEventListener('input', mark);
|
|
112
|
+
saveBtn.addEventListener('click', async () => {
|
|
113
|
+
saveBtn.disabled = true;
|
|
114
|
+
status.textContent = 'Saving…';
|
|
115
|
+
try {
|
|
116
|
+
const r = await opts.save(payload(list));
|
|
117
|
+
list = rows(r.rules);
|
|
118
|
+
editing = null;
|
|
119
|
+
status.textContent = r.message || 'Saved.';
|
|
120
|
+
} catch (e) { status.textContent = (e && e.message) || 'Could not save. Is the host awake?'; }
|
|
121
|
+
draw();
|
|
122
|
+
});
|
|
123
|
+
return { set(rules) { list = rows(rules); editing = null; draw(); } };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
globalThis.RulesEditor = { rows, payload, dirty, render, mount, CSS };
|
|
127
|
+
})();
|