threadroom-pi 0.1.0-beta.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 +110 -0
- package/extensions/README.md +15 -0
- package/extensions/index.ts +280 -0
- package/extensions/native/index.ts +614 -0
- package/extensions/native/presentation.ts +30 -0
- package/extensions/native/receipt.ts +25 -0
- package/extensions/native/ui.ts +167 -0
- package/extensions/presentation/renderers.ts +185 -0
- package/extensions/questions/README.md +41 -0
- package/extensions/questions/compose.ts +82 -0
- package/extensions/questions/external-editor.ts +24 -0
- package/extensions/questions/host.ts +415 -0
- package/extensions/questions/index.ts +6 -0
- package/extensions/questions/model.ts +175 -0
- package/extensions/questions/stream.ts +299 -0
- package/extensions/questions/text.ts +10 -0
- package/extensions/questions/tool.ts +309 -0
- package/extensions/questions/types.ts +40 -0
- package/extensions/questions/view.ts +221 -0
- package/node_modules/threadroom-service/README.md +73 -0
- package/node_modules/threadroom-service/bin/threadroom-service.js +9 -0
- package/node_modules/threadroom-service/dist/public/app.js +349 -0
- package/node_modules/threadroom-service/dist/public/assets/mist-bloom.svg +17 -0
- package/node_modules/threadroom-service/dist/public/assets/mist-drift.svg +16 -0
- package/node_modules/threadroom-service/dist/public/assets/mist-prowler.svg +15 -0
- package/node_modules/threadroom-service/dist/public/client.js +30 -0
- package/node_modules/threadroom-service/dist/public/index.html +54 -0
- package/node_modules/threadroom-service/dist/public/presentations.js +194 -0
- package/node_modules/threadroom-service/dist/public/routes.js +15 -0
- package/node_modules/threadroom-service/dist/public/styles.css +263 -0
- package/node_modules/threadroom-service/dist/src/live.js +170 -0
- package/node_modules/threadroom-service/dist/src/main.js +33 -0
- package/node_modules/threadroom-service/dist/src/presentations.js +116 -0
- package/node_modules/threadroom-service/dist/src/server.js +143 -0
- package/node_modules/threadroom-service/dist/src/site.js +53 -0
- package/node_modules/threadroom-service/dist/src/store.js +459 -0
- package/node_modules/threadroom-service/dist/src/ui-main.js +14 -0
- package/node_modules/threadroom-service/lib/cli.js +188 -0
- package/node_modules/threadroom-service/lib/ensure.js +157 -0
- package/node_modules/threadroom-service/lib/paths.js +19 -0
- package/node_modules/threadroom-service/package.json +19 -0
- package/package.json +50 -0
- package/scripts/stage-service.js +32 -0
- package/scripts/verify-packed.js +85 -0
- package/scripts/verify-release.js +79 -0
- package/src/client.js +135 -0
- package/src/config.js +57 -0
- package/src/http-transport.js +44 -0
- package/src/participation.js +211 -0
- package/src/service-runtime.js +43 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { ThreadroomClient } from './client.js';
|
|
2
|
+
import { mountPresentation } from './presentations.js';
|
|
3
|
+
import { threadIdFromPath, threadPath } from './routes.js';
|
|
4
|
+
|
|
5
|
+
const $ = (selector, root = document) => root.querySelector(selector);
|
|
6
|
+
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
|
|
7
|
+
const esc = (value = '') => String(value ?? '').replace(/[&<>'"]/g, (char) => ({ '&':'&', '<':'<', '>':'>', "'":''', '"':'"' })[char]);
|
|
8
|
+
const initials = (name = '?') => name.replace(/\([^)]*\)/g,'').trim().split(/\s+/).map((part) => part[0]).join('').slice(0,2).toUpperCase();
|
|
9
|
+
const time = (iso) => new Intl.DateTimeFormat(undefined, { month:'short', day:'numeric', hour:'numeric', minute:'2-digit' }).format(new Date(iso));
|
|
10
|
+
const statusLabels = { outstanding:'NEEDS YOUR ANSWER', answered:'ANSWERED', waiting_on_team:'WAITING ON TEAM', deferred:'DEFERRED', rejected:'REJECTED' };
|
|
11
|
+
const responseLabels = { answer:'Answered', reject:'Rejected with reason', clarification:'Asked back', defer:'Deferred', team_reply:'Team replied' };
|
|
12
|
+
const state = { nodes:[], byId:new Map(), children:new Map(), expanded:new Set(), selected:null, zoomId:null, view:'outline', query:'', dialogParentId:null, drafts:new Map(), saving:new Map(), readGeneration:0, readingId:null };
|
|
13
|
+
let client;
|
|
14
|
+
let cleanupCanvas = () => {};
|
|
15
|
+
|
|
16
|
+
function rebuildTree(nodes) {
|
|
17
|
+
state.nodes = nodes;
|
|
18
|
+
state.byId = new Map(nodes.map((node) => [node.id,node]));
|
|
19
|
+
state.children = new Map();
|
|
20
|
+
for (const node of nodes) {
|
|
21
|
+
const parentId = node.parentId ?? null;
|
|
22
|
+
state.children.set(parentId, [...(state.children.get(parentId) || []),node]);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const childrenOf = (id) => state.children.get(id) || [];
|
|
26
|
+
function ancestorsOf(id) {
|
|
27
|
+
const result = [];
|
|
28
|
+
let node = state.byId.get(id);
|
|
29
|
+
while (node?.parentId) { node = state.byId.get(node.parentId); if (node) result.unshift(node); }
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
function inBranch(node, rootId) { return !rootId || node.id === rootId || ancestorsOf(node.id).some((parent) => parent.id === rootId); }
|
|
33
|
+
|
|
34
|
+
async function refreshTree() {
|
|
35
|
+
const { nodes } = await client.tree();
|
|
36
|
+
rebuildTree(nodes);
|
|
37
|
+
renderSidebar();
|
|
38
|
+
renderOutline();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function renderSidebar() {
|
|
42
|
+
$('#needsCount').textContent = state.nodes.filter((node) => node.status === 'outstanding').length;
|
|
43
|
+
$('#teamCount').textContent = state.nodes.filter((node) => node.status === 'waiting_on_team').length;
|
|
44
|
+
$('#deferredCount').textContent = state.nodes.filter((node) => node.status === 'deferred').length;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function renderOutline() {
|
|
48
|
+
const zoomed = state.view === 'outline' ? state.byId.get(state.zoomId) : null;
|
|
49
|
+
const labels = { outline:['OUTLINE',zoomed?.title || 'Everything'], outstanding:['INBOX','Needs your answer'], waiting_on_team:['OUTBOUND','Waiting on team'], deferred:['LATER','Deferred'] };
|
|
50
|
+
$('#viewEyebrow').textContent = labels[state.view][0];
|
|
51
|
+
$('#viewTitle').textContent = labels[state.view][1];
|
|
52
|
+
const crumbs = zoomed ? [...ancestorsOf(zoomed.id),zoomed] : [];
|
|
53
|
+
$('#outlineCrumbs').innerHTML = `<button data-zoom="">Home</button>${crumbs.map((node) => `<span>›</span><button data-zoom="${esc(node.id)}">${esc(node.title)}</button>`).join('')}`;
|
|
54
|
+
$$('[data-zoom]', $('#outlineCrumbs')).forEach((button) => button.addEventListener('click', () => zoom(button.dataset.zoom || null)));
|
|
55
|
+
const query = state.query.toLowerCase();
|
|
56
|
+
let html;
|
|
57
|
+
if (state.view !== 'outline' || query) {
|
|
58
|
+
const nodes = state.nodes.filter((node) => (state.view !== 'outline' || inBranch(node,state.zoomId)) && (state.view === 'outline' || node.status === state.view) && (!query || [node.title,node.author.name].join(' ').toLowerCase().includes(query)));
|
|
59
|
+
html = nodes.map((node) => `<div class="outline-context">${esc(ancestorsOf(node.id).map((parent) => parent.title).join(' › '))}</div>${outlineRow(node,0,false)}`).join('');
|
|
60
|
+
} else {
|
|
61
|
+
const roots = childrenOf(state.zoomId);
|
|
62
|
+
const stack = [...roots].reverse().map((node) => ({node,depth:0}));
|
|
63
|
+
const rows = [];
|
|
64
|
+
while (stack.length) {
|
|
65
|
+
const {node,depth} = stack.pop();
|
|
66
|
+
rows.push(outlineRow(node,depth,true));
|
|
67
|
+
if (state.expanded.has(node.id)) for (const child of [...childrenOf(node.id)].reverse()) stack.push({node:child,depth:depth+1});
|
|
68
|
+
}
|
|
69
|
+
html = rows.join('');
|
|
70
|
+
}
|
|
71
|
+
$('#threadList').innerHTML = html || '<div class="empty-list">No items in this view.<br>You can add a thread at any depth.</div>';
|
|
72
|
+
$$('.outline-row').forEach((row) => row.style.setProperty('--depth',row.dataset.depth));
|
|
73
|
+
$$('[data-select]').forEach((button) => button.addEventListener('click', () => openNode(button.dataset.select)));
|
|
74
|
+
$$('[data-expand]').forEach((button) => button.addEventListener('click', () => { const id = button.dataset.expand; state.expanded.has(id) ? state.expanded.delete(id) : state.expanded.add(id); renderOutline(); }));
|
|
75
|
+
$$('[data-bullet]').forEach((button) => button.addEventListener('click', () => zoom(button.dataset.bullet)));
|
|
76
|
+
$$('[data-add-child]').forEach((button) => button.addEventListener('click', () => showPublish(button.dataset.addChild)));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function outlineRow(node,depth,tree) {
|
|
80
|
+
const kids = childrenOf(node.id);
|
|
81
|
+
const isExpanded = state.expanded.has(node.id);
|
|
82
|
+
return `<div class="outline-row ${node.id === state.selected?.node.id ? 'selected' : ''}" data-depth="${Math.min(depth,30)}">
|
|
83
|
+
<button class="outline-toggle ${kids.length ? '' : 'no-children'}" data-expand="${esc(node.id)}" aria-label="${isExpanded ? 'Collapse' : 'Expand'} ${esc(node.title)}">${tree && isExpanded ? '⌄' : '›'}</button>
|
|
84
|
+
<button class="outline-bullet" data-bullet="${esc(node.id)}" title="Zoom into this thread" aria-label="Zoom into ${esc(node.title)}">•</button>
|
|
85
|
+
<button class="outline-title" data-select="${esc(node.id)}"><span>${esc(node.title)}</span>${node.status ? `<small class="outline-state ${esc(node.status)}">${esc(statusLabels[node.status] || node.status)}</small>` : ''}</button>
|
|
86
|
+
<button class="outline-add" data-add-child="${esc(node.id)}" title="Add a child thread" aria-label="Branch from ${esc(node.title)}">+</button>
|
|
87
|
+
</div>`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function zoom(id) {
|
|
91
|
+
state.zoomId = id;
|
|
92
|
+
state.view = 'outline';
|
|
93
|
+
$$('.nav-item').forEach((button) => button.classList.toggle('active',button.dataset.view === 'outline'));
|
|
94
|
+
renderSidebar(); renderOutline();
|
|
95
|
+
if (id) openNode(id);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function openNode(id,{navigate=true}={}) {
|
|
99
|
+
const generation = ++state.readGeneration;
|
|
100
|
+
state.readingId = id;
|
|
101
|
+
try {
|
|
102
|
+
const result = await client.read(id);
|
|
103
|
+
if (generation !== state.readGeneration) return;
|
|
104
|
+
state.selected = result;
|
|
105
|
+
for (const ancestor of result.ancestors) state.expanded.add(ancestor.id);
|
|
106
|
+
const path = threadPath(id);
|
|
107
|
+
if (navigate && location.pathname !== path) history.pushState({id},'',path);
|
|
108
|
+
renderOutline(); renderRoom(result);
|
|
109
|
+
} catch (error) { toast(error.message,true); }
|
|
110
|
+
finally { if (generation === state.readGeneration) state.readingId = null; }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function renderRoom(result) {
|
|
114
|
+
cleanupCanvas();
|
|
115
|
+
const {node,ancestors,children,counts} = result;
|
|
116
|
+
const draft = draftFor(node.id);
|
|
117
|
+
$('#threadRoom').scrollTop = 0;
|
|
118
|
+
$('#threadRoom').innerHTML = `
|
|
119
|
+
<header class="room-header"><div class="room-title-row"><span class="room-author">${esc(initials(node.author.name))}</span><div><div class="room-breadcrumbs"><button data-room-zoom="">Home</button>${ancestors.map((parent) => `<span>›</span><button data-room-zoom="${esc(parent.id)}">${esc(parent.title)}</button>`).join('')}</div><h1>${esc(node.title)}</h1><div class="room-meta">${esc(node.author.name)}${node.author.role ? ` · ${esc(node.author.role)}` : ''} · ${time(node.createdAt)}</div></div></div><div class="room-actions"><button class="room-action" id="copyLinkButton">Copy link</button><button class="room-action primary" id="branchButton">+ Branch here</button></div></header>
|
|
120
|
+
<div class="room-content node-content">
|
|
121
|
+
<div class="node-heading"><span class="node-context">${esc(nodeContext(node))}</span><span id="selectedStatus">${statusChip(node.status)}</span></div>
|
|
122
|
+
${node.body ? `<p class="node-body">${esc(node.body)}</p>` : ''}
|
|
123
|
+
${node.response ? renderSavedValues(node.response.selections) : ''}
|
|
124
|
+
<div class="node-canvas" id="nodeCanvas"></div>
|
|
125
|
+
<div class="conversation-label">INSIDE THIS THREAD <span id="childCount">${children.length}</span></div>
|
|
126
|
+
<div id="childThreads">${renderChildren(children)}</div>
|
|
127
|
+
<div class="trusted-response" id="trustedResponse">
|
|
128
|
+
<div class="trusted-heading"><span>▣ THREADROOM RESPONSE</span><small>Always available · outside the authored canvas</small></div>
|
|
129
|
+
<div class="composer">
|
|
130
|
+
<div class="composer-modes">${[['answer','Answer with text'],['clarification','Ask back'],['defer','Defer'],['reject','Reject with reason']].map(([kind,label]) => `<button class="mode-button ${draft.kind === kind ? 'active' : ''}" data-mode="${kind}">${label}</button>`).join('')}</div>
|
|
131
|
+
<div class="proposal-summary" id="proposalSummary">${proposalSummary(draft.proposal)}</div>
|
|
132
|
+
<textarea id="responseBody" aria-label="Written response" placeholder="Answer in your own words. You never have to use the author’s proposed controls.">${esc(draft.body)}</textarea>
|
|
133
|
+
<details class="authoring-details response-authoring"><summary>Include an authored answer canvas</summary><label>Self-contained HTML/CSS/JS<textarea id="responseHtml" aria-label="Answer canvas HTML" rows="4">${esc(draft.html)}</textarea></label><label>Readable fallback<textarea id="responseFallback" aria-label="Answer canvas readable fallback" rows="2">${esc(draft.fallback)}</textarea></label></details>
|
|
134
|
+
<div class="composer-footer"><span class="draft-state" id="draftState">${draft.body || draft.proposal ? 'Unsaved draft kept in this browser' : 'Your text answer / rejection cannot be overridden'}</span><button class="submit-response" id="saveResponseButton" ${state.saving.has(node.id) ? 'disabled' : ''}>${state.saving.has(node.id) ? 'Saving…' : 'Save response →'}</button></div>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
<p class="node-footnote">${counts.outstanding} awaiting human input · ${counts.waitingOnTeam} waiting on team · ${counts.deferred} deferred in this branch. You can branch from any thread.</p>
|
|
138
|
+
</div>`;
|
|
139
|
+
$$('[data-room-zoom]').forEach((button) => button.addEventListener('click',() => zoom(button.dataset.roomZoom || null)));
|
|
140
|
+
$('#copyLinkButton').addEventListener('click',async () => { try { await navigator.clipboard.writeText(location.href); toast('Thread link copied'); } catch { toast('Copy the address from the browser'); } });
|
|
141
|
+
$('#branchButton').addEventListener('click',() => showPublish(node.id));
|
|
142
|
+
bindChildren();
|
|
143
|
+
cleanupCanvas = mountPresentation($('#nodeCanvas'), { node, apiBaseUrl:client.baseUrl, onProposal:(proposal) => {
|
|
144
|
+
const current = draftFor(node.id);
|
|
145
|
+
current.proposal = proposal;
|
|
146
|
+
keepDraft(node.id,current);
|
|
147
|
+
if (state.selected?.node.id === node.id) { $('#proposalSummary').innerHTML = proposalSummary(proposal); $('#draftState').textContent = 'Canvas values proposed—not saved until you submit here'; }
|
|
148
|
+
}});
|
|
149
|
+
$$('.mode-button').forEach((button) => button.addEventListener('click',() => {
|
|
150
|
+
draft.kind = button.dataset.mode;
|
|
151
|
+
$$('.mode-button').forEach((other) => other.classList.toggle('active',other === button));
|
|
152
|
+
$('#responseBody').placeholder = draft.kind === 'reject' ? 'Explain why you reject the premise or direction…' : draft.kind === 'clarification' ? 'What do you need the team to clarify?' : draft.kind === 'defer' ? 'Optional: when or what should bring this back?' : 'Answer in your own words…';
|
|
153
|
+
keepDraft(node.id,draft);
|
|
154
|
+
}));
|
|
155
|
+
const updateDraft = () => {
|
|
156
|
+
draft.body = $('#responseBody').value; draft.html = $('#responseHtml').value; draft.fallback = $('#responseFallback').value;
|
|
157
|
+
keepDraft(node.id,draft); $('#draftState').textContent = 'Unsaved draft kept in this browser';
|
|
158
|
+
};
|
|
159
|
+
['#responseBody','#responseHtml','#responseFallback'].forEach((selector) => $(selector).addEventListener('input',updateDraft));
|
|
160
|
+
$('#saveResponseButton').addEventListener('click',() => saveResponse(node.id));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function renderChildren(children) {
|
|
164
|
+
if (!children.length) return '<p class="children-empty">Nothing nested here yet. Reply below or branch here.</p>';
|
|
165
|
+
return children.map((child) => `<article class="child-thread">
|
|
166
|
+
<div class="child-top"><span class="author-avatar">${esc(initials(child.author.name))}</span><span class="thread-author">${esc(child.author.name)}</span>${nodeContext(child) ? `<span class="node-context">${esc(nodeContext(child))}</span>` : ''}<span class="response-time">${time(child.createdAt)}</span>${statusChip(child.status)}</div>
|
|
167
|
+
<button class="child-title" data-open-child="${esc(child.id)}">${esc(child.title)} <span>↗</span></button>
|
|
168
|
+
${child.body ? `<p class="child-body">${esc(child.body)}</p>` : ''}
|
|
169
|
+
${child.response ? renderSavedValues(child.response.selections) : ''}
|
|
170
|
+
${child.presentation ? `<span class="child-artifact">${child.presentation.kind === 'html-v1' ? 'Authored interactive canvas + readable record' : 'Review presentation'} · ${esc(child.presentation.revision)}</span>` : ''}
|
|
171
|
+
<div class="child-actions"><button data-open-child="${esc(child.id)}">Open / zoom</button><button data-branch-child="${esc(child.id)}">+ Branch here</button></div>
|
|
172
|
+
</article>`).join('');
|
|
173
|
+
}
|
|
174
|
+
function bindChildren() {
|
|
175
|
+
$$('[data-open-child]').forEach((button) => button.addEventListener('click',() => zoom(button.dataset.openChild)));
|
|
176
|
+
$$('[data-branch-child]').forEach((button) => button.addEventListener('click',() => showPublish(button.dataset.branchChild)));
|
|
177
|
+
}
|
|
178
|
+
function nodeContext(node) {
|
|
179
|
+
return [node.expectsAnswer ? 'Answer requested' : '', node.response ? responseLabels[node.response.kind] || 'Saved response' : ''].filter(Boolean).join(' · ');
|
|
180
|
+
}
|
|
181
|
+
const statusChip = (status) => status ? `<span class="status-chip ${esc(status)}">${statusLabels[status] || esc(status)}</span>` : '';
|
|
182
|
+
function renderSavedValues(selections=[]) {
|
|
183
|
+
return selections.map((selection) => {
|
|
184
|
+
// Images stay in image context (SVG cannot execute as an <img> document).
|
|
185
|
+
// Recognize captures without exposing encoded image bytes as readable notes.
|
|
186
|
+
const captures = ['questionImage','image','snapshot'].filter((key) => {
|
|
187
|
+
const value = selection.value?.[key];
|
|
188
|
+
return typeof value === 'string' && /^data:image\/(png|jpeg|webp|gif|svg\+xml)(?:;[^,]*)?,/i.test(value);
|
|
189
|
+
});
|
|
190
|
+
const readable = captures.length ? {...selection.value} : selection.value;
|
|
191
|
+
for (const key of captures) readable[key] = '[Captured image shown above]';
|
|
192
|
+
const images = captures.map((key) => `<img class="saved-generation" src="${esc(selection.value[key])}" alt="${key === 'questionImage' ? 'Captured question reference' : 'Captured visual answer'}">`).join('');
|
|
193
|
+
const notes = typeof selection.value?.notes === 'string' && selection.value.notes.trim() ? `<p>${esc(selection.value.notes)}</p>` : '';
|
|
194
|
+
return `<div class="saved-values"><strong>${esc(selection.label || selection.id || 'Interaction values')}</strong>${images}${notes}${readable != null ? `<pre>${esc(JSON.stringify(readable,null,2).slice(0,3000))}</pre>` : ''}</div>`;
|
|
195
|
+
}).join('');
|
|
196
|
+
}
|
|
197
|
+
function proposalSummary(proposal) {
|
|
198
|
+
return proposal ? `<strong>Unsaved canvas proposal</strong><span>${esc(proposal.summary || JSON.stringify(proposal.values).slice(0,300))}</span><small>Only the Threadroom Save button records this.</small>` : '';
|
|
199
|
+
}
|
|
200
|
+
function draftFor(id) {
|
|
201
|
+
if (state.drafts.has(id)) return state.drafts.get(id);
|
|
202
|
+
let saved = {};
|
|
203
|
+
try { saved = JSON.parse(localStorage.getItem(`threadroom:node-draft:${id}`) || '{}'); } catch {}
|
|
204
|
+
const draft = { kind:'answer', body:'', html:'', fallback:'', proposal:null, ...saved };
|
|
205
|
+
state.drafts.set(id,draft); return draft;
|
|
206
|
+
}
|
|
207
|
+
function keepDraft(id,draft) {
|
|
208
|
+
state.drafts.set(id,draft);
|
|
209
|
+
try { localStorage.setItem(`threadroom:node-draft:${id}`,JSON.stringify(draft)); } catch { toast('Draft remains on this page; browser storage is full',true); }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function responsePayload(draft) {
|
|
213
|
+
return {
|
|
214
|
+
kind:draft.kind, body:draft.body, author:{name:'Scott'},
|
|
215
|
+
selections:draft.proposal && draft.kind !== 'reject' ? [{id:'authored-values',label:draft.proposal.summary || 'Canvas interaction',value:draft.proposal.values}] : [],
|
|
216
|
+
...(draft.html ? {presentation:{kind:'html-v1',html:draft.html,fallback:draft.fallback}} : {})
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
async function saveResponse(id) {
|
|
220
|
+
if (state.saving.has(id)) return;
|
|
221
|
+
const draft = draftFor(id);
|
|
222
|
+
const payload = responsePayload(draft);
|
|
223
|
+
const serialized = JSON.stringify(payload);
|
|
224
|
+
if (draft.attempt?.payload !== serialized) draft.attempt = {key:crypto.randomUUID(),payload:serialized};
|
|
225
|
+
const attempt = draft.attempt;
|
|
226
|
+
state.saving.set(id,attempt);
|
|
227
|
+
keepDraft(id,draft);
|
|
228
|
+
const button = $('#saveResponseButton'); button.disabled = true; button.textContent = 'Saving…';
|
|
229
|
+
try {
|
|
230
|
+
await client.respond(id,payload,attempt.key);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
state.saving.delete(id);
|
|
233
|
+
if (state.selected?.node.id === id) { const currentButton = $('#saveResponseButton'); currentButton.disabled = false; currentButton.textContent = 'Retry save →'; }
|
|
234
|
+
toast(`${error.message}. Your draft is still here.`,true);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
state.saving.delete(id);
|
|
238
|
+
const currentDraft = draftFor(id);
|
|
239
|
+
const hasNewerDraft = JSON.stringify(responsePayload(currentDraft)) !== serialized;
|
|
240
|
+
let draftCleanupFailed = false;
|
|
241
|
+
if (hasNewerDraft) { currentDraft.attempt = null; keepDraft(id,currentDraft); }
|
|
242
|
+
else {
|
|
243
|
+
state.drafts.delete(id);
|
|
244
|
+
// Browser storage is an optional draft convenience. Once the server has
|
|
245
|
+
// committed the response, a storage policy/quota failure must not interrupt
|
|
246
|
+
// the visible success path or resurrect the submitted draft on re-render.
|
|
247
|
+
try { localStorage.removeItem(`threadroom:node-draft:${id}`); }
|
|
248
|
+
catch {
|
|
249
|
+
draftCleanupFailed = true;
|
|
250
|
+
state.drafts.set(id,{kind:'answer',body:'',html:'',fallback:'',proposal:null});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// A committed save never owns navigation or edits made after the submitted snapshot.
|
|
254
|
+
if (state.selected?.node.id === id) {
|
|
255
|
+
const currentButton = $('#saveResponseButton'); currentButton.disabled = false;
|
|
256
|
+
currentButton.textContent = hasNewerDraft ? 'Save newer draft →' : 'Save response →';
|
|
257
|
+
$('#draftState').textContent = hasNewerDraft ? 'Submitted snapshot saved; newer draft is still unsaved' : 'Response saved to history';
|
|
258
|
+
if (!hasNewerDraft) {
|
|
259
|
+
// Returning to this node while its save was in flight renders the submitted
|
|
260
|
+
// draft again. Clear that visible composer locally without incrementing the
|
|
261
|
+
// read generation: a slower navigation to another node may already be in
|
|
262
|
+
// flight and must retain ownership. Refresh saved history only when idle.
|
|
263
|
+
renderRoom(state.selected);
|
|
264
|
+
if (state.readingId === null) await openNode(id,{navigate:false});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
await refreshTree();
|
|
269
|
+
toast(draftCleanupFailed
|
|
270
|
+
? 'Response saved, but the stale browser draft could not be removed. This page will keep it cleared; check browser storage before reloading.'
|
|
271
|
+
: hasNewerDraft ? 'Submitted response saved. Your newer draft was kept.' : 'Saved. Your response is in this branch.', draftCleanupFailed);
|
|
272
|
+
} catch {
|
|
273
|
+
toast(draftCleanupFailed
|
|
274
|
+
? 'Response saved, but browser draft cleanup failed and the outline will catch up when reconnected.'
|
|
275
|
+
: 'Response saved. Outline will catch up when reconnected.', draftCleanupFailed);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function showPublish(parentId) {
|
|
280
|
+
state.dialogParentId = parentId || null;
|
|
281
|
+
const parent = state.byId.get(parentId);
|
|
282
|
+
$('#publishContext').textContent = parent ? `Inside: ${[...ancestorsOf(parentId),parent].map((node) => node.title).join(' › ')}` : 'In Everything';
|
|
283
|
+
$('#newThreadDialog').showModal();
|
|
284
|
+
}
|
|
285
|
+
$('#newThreadForm').addEventListener('submit',async (event) => {
|
|
286
|
+
event.preventDefault(); if (event.submitter?.value === 'cancel') return $('#newThreadDialog').close();
|
|
287
|
+
const form = event.currentTarget; const data = new FormData(form);
|
|
288
|
+
const payload = { parentId:state.dialogParentId, title:data.get('title'), body:data.get('body'), expectsAnswer:data.has('expectsAnswer'), author:{name:'Scott'},
|
|
289
|
+
...(data.get('html') ? {presentation:{kind:'html-v1',html:data.get('html'),fallback:data.get('fallback')}} : {}) };
|
|
290
|
+
const serialized = JSON.stringify(payload);
|
|
291
|
+
if (state.publishAttempt?.payload !== serialized) state.publishAttempt = {key:crypto.randomUUID(),payload:serialized};
|
|
292
|
+
let result;
|
|
293
|
+
try { result = await client.publish(payload,state.publishAttempt.key); }
|
|
294
|
+
catch (error) { toast(`${error.message}. The form is retained for retry.`,true); return; }
|
|
295
|
+
state.publishAttempt = null;
|
|
296
|
+
$('#newThreadDialog').close(); form.reset();
|
|
297
|
+
const updated = new Map(state.nodes.map((node) => [node.id,node]));
|
|
298
|
+
for (const node of [...result.ancestors,result.node,...result.children]) updated.set(node.id,{...updated.get(node.id),...node});
|
|
299
|
+
rebuildTree([...updated.values()]);
|
|
300
|
+
for (const ancestor of result.ancestors) state.expanded.add(ancestor.id);
|
|
301
|
+
if (!result.node.parentId) state.zoomId = null;
|
|
302
|
+
++state.readGeneration;
|
|
303
|
+
state.readingId = null;
|
|
304
|
+
state.selected = result;
|
|
305
|
+
history.pushState({id:result.node.id},'',threadPath(result.node.id));
|
|
306
|
+
renderSidebar(); renderOutline(); renderRoom(result);
|
|
307
|
+
toast('Node added here');
|
|
308
|
+
});
|
|
309
|
+
$('#newThreadButton').addEventListener('click',() => showPublish(state.zoomId));
|
|
310
|
+
$('#addInBranch').addEventListener('click',() => showPublish(state.zoomId));
|
|
311
|
+
$('#outlineUp').addEventListener('click',() => zoom(state.byId.get(state.zoomId)?.parentId || null));
|
|
312
|
+
$('#searchInput').addEventListener('input',(event) => { state.query = event.target.value; renderOutline(); });
|
|
313
|
+
$('#refreshButton').addEventListener('click',async () => { try { await refreshTree(); if (state.selected) await openNode(state.selected.node.id,{navigate:false}); toast('Caught up with saved history'); } catch (error) { toast(error.message,true); } });
|
|
314
|
+
// Sidebar views are global; an outline zoom must not hide incoming questions in another branch.
|
|
315
|
+
$$('.nav-item').forEach((button) => button.addEventListener('click',() => { state.view = button.dataset.view; state.zoomId = null; $$('.nav-item').forEach((other) => other.classList.toggle('active',other === button)); renderOutline(); }));
|
|
316
|
+
window.addEventListener('popstate',() => { const id = threadIdFromPath(location.pathname); if (id) openNode(id,{navigate:false}); });
|
|
317
|
+
function toast(message,error=false) {
|
|
318
|
+
const element = $('#toast'); element.textContent = message; element.classList.toggle('error',error); element.classList.add('show'); clearTimeout(toast.timer); toast.timer = setTimeout(() => element.classList.remove('show'),3500);
|
|
319
|
+
}
|
|
320
|
+
let eventTimer;
|
|
321
|
+
async function catchUp() {
|
|
322
|
+
try {
|
|
323
|
+
await refreshTree();
|
|
324
|
+
const id = state.selected?.node.id;
|
|
325
|
+
if (id) {
|
|
326
|
+
const result = await client.read(id);
|
|
327
|
+
if (state.selected?.node.id !== id) return;
|
|
328
|
+
state.selected = result;
|
|
329
|
+
$('#selectedStatus').innerHTML = statusChip(result.node.status);
|
|
330
|
+
$('#childCount').textContent = result.children.length;
|
|
331
|
+
$('#childThreads').innerHTML = renderChildren(result.children);
|
|
332
|
+
bindChildren();
|
|
333
|
+
}
|
|
334
|
+
} catch { /* EventSource owns reconnection; a manual refresh stays available. */ }
|
|
335
|
+
}
|
|
336
|
+
async function start() {
|
|
337
|
+
const config = await fetch('/threadroom-config.json').then((response) => response.json());
|
|
338
|
+
client = new ThreadroomClient(config.apiBaseUrl);
|
|
339
|
+
await refreshTree();
|
|
340
|
+
const pathId = threadIdFromPath(location.pathname);
|
|
341
|
+
const id = pathId && state.byId.has(pathId) ? pathId : state.nodes.find((node) => node.id === 'q_silhouette')?.id || childrenOf(null)[0]?.id;
|
|
342
|
+
for (const root of childrenOf(null)) state.expanded.add(root.id);
|
|
343
|
+
if (id) await openNode(id);
|
|
344
|
+
client.subscribe(() => { clearTimeout(eventTimer); eventTimer = setTimeout(catchUp,60); }, {onState:(connection) => {
|
|
345
|
+
$('#connectionLabel').textContent = connection === 'live' ? 'Live updates · conversations outlast sessions' : 'Reconnecting · saved history stays in the backend';
|
|
346
|
+
$('#connectionDot').classList.toggle('disconnected',connection !== 'live');
|
|
347
|
+
}});
|
|
348
|
+
}
|
|
349
|
+
start().catch((error) => { $('#threadRoom').innerHTML = `<div class="thread-loading">Could not connect to Threadroom.<br>${esc(error.message)}</div>`; toast(error.message,true); });
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 460" role="img" aria-labelledby="title desc">
|
|
2
|
+
<title id="title">The Bloom</title><desc id="desc">A radial mist creature opening like a many-petaled flower</desc>
|
|
3
|
+
<defs><radialGradient id="bg"><stop stop-color="#34423f"/><stop offset="1" stop-color="#10191e"/></radialGradient><filter id="glow"><feGaussianBlur stdDeviation="10"/></filter></defs>
|
|
4
|
+
<rect width="640" height="460" fill="url(#bg)"/>
|
|
5
|
+
<g transform="translate(334 253)" fill="#122021" stroke="#bad9c7" stroke-width="3">
|
|
6
|
+
<path d="M0 0 C-45 -53 -66 -122 -24 -181 C-8 -126 35 -87 0 0Z"/>
|
|
7
|
+
<path d="M0 0 C28 -64 81 -113 151 -105 C113 -63 101 -6 0 0Z"/>
|
|
8
|
+
<path d="M0 0 C69 -5 138 17 160 83 C105 65 52 86 0 0Z"/>
|
|
9
|
+
<path d="M0 0 C44 54 63 124 18 180 C4 124 -37 83 0 0Z"/>
|
|
10
|
+
<path d="M0 0 C-29 64 -84 111 -153 100 C-113 60 -99 2 0 0Z"/>
|
|
11
|
+
<path d="M0 0 C-69 3 -137 -21 -156 -88 C-102 -68 -48 -88 0 0Z"/>
|
|
12
|
+
<circle r="54" fill="#172728"/><circle r="18" fill="#a8cdb8" opacity=".8"/>
|
|
13
|
+
</g>
|
|
14
|
+
<circle cx="334" cy="253" r="108" fill="none" stroke="#bbd7c3" stroke-width="35" opacity=".09" filter="url(#glow)"/>
|
|
15
|
+
<g fill="#dcebdd" opacity=".35"><circle cx="120" cy="318" r="3"/><circle cx="178" cy="112" r="5"/><circle cx="531" cy="182" r="4"/><circle cx="554" cy="356" r="3"/><circle cx="80" cy="217" r="2"/></g>
|
|
16
|
+
<text x="32" y="46" fill="#e4f1e9" font-family="system-ui" font-size="18" letter-spacing="3">SILHOUETTE STUDY C</text>
|
|
17
|
+
</svg>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 460" role="img" aria-labelledby="title desc">
|
|
2
|
+
<title id="title">The Drift</title><desc id="desc">A tall, narrow mist creature with a long flowing wake</desc>
|
|
3
|
+
<defs><radialGradient id="bg" cx="42%" cy="38%" r="80%"><stop stop-color="#32424a"/><stop offset="1" stop-color="#10191e"/></radialGradient><filter id="blur"><feGaussianBlur stdDeviation="12"/></filter></defs>
|
|
4
|
+
<rect width="640" height="460" fill="url(#bg)"/><circle cx="480" cy="88" r="92" fill="#b9dbc9" opacity=".08"/>
|
|
5
|
+
<g fill="none" stroke="#9ccab9" stroke-linecap="round">
|
|
6
|
+
<path d="M314 62 C278 132 278 199 307 254 C326 292 278 343 222 414" stroke-width="64" opacity=".15" filter="url(#blur)"/>
|
|
7
|
+
<path d="M323 58 C287 124 289 197 317 249 C340 291 294 346 240 416" stroke-width="22" opacity=".78"/>
|
|
8
|
+
<path d="M305 132 C260 178 243 235 255 292" stroke-width="9" opacity=".53"/>
|
|
9
|
+
<path d="M336 139 C388 196 392 248 371 305" stroke-width="11" opacity=".44"/>
|
|
10
|
+
<path d="M319 252 C375 307 390 361 362 428" stroke-width="7" opacity=".35"/>
|
|
11
|
+
</g>
|
|
12
|
+
<ellipse cx="319" cy="104" rx="31" ry="49" fill="#152329" stroke="#b7dfca" stroke-width="3"/>
|
|
13
|
+
<circle cx="309" cy="98" r="4" fill="#e7ffdf"/><circle cx="329" cy="98" r="4" fill="#e7ffdf"/>
|
|
14
|
+
<path d="M70 377 C177 331 217 370 288 344 C388 307 466 347 570 310" fill="none" stroke="#d4eadb" opacity=".11" stroke-width="24" filter="url(#blur)"/>
|
|
15
|
+
<text x="32" y="46" fill="#e4f1e9" font-family="system-ui" font-size="18" letter-spacing="3">SILHOUETTE STUDY A</text>
|
|
16
|
+
</svg>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 460" role="img" aria-labelledby="title desc">
|
|
2
|
+
<title id="title">The Prowler</title><desc id="desc">A wide crouched mist creature with forward-reaching limbs</desc>
|
|
3
|
+
<defs><radialGradient id="bg" cx="48%" cy="58%" r="82%"><stop stop-color="#3d4d4a"/><stop offset="1" stop-color="#10191e"/></radialGradient><filter id="blur"><feGaussianBlur stdDeviation="15"/></filter></defs>
|
|
4
|
+
<rect width="640" height="460" fill="url(#bg)"/><circle cx="138" cy="96" r="105" fill="#d8bc83" opacity=".07"/>
|
|
5
|
+
<g fill="#101b1d" stroke="#b8d2bd" stroke-linejoin="round">
|
|
6
|
+
<path d="M176 278 C218 181 350 160 430 221 C466 248 488 292 477 320 C425 292 377 289 334 304 C272 327 217 320 176 278Z" stroke-width="4"/>
|
|
7
|
+
<path d="M204 287 C159 302 119 342 78 406 C149 379 207 350 263 315Z" stroke-width="8"/>
|
|
8
|
+
<path d="M389 291 C454 303 512 339 581 402 C500 378 434 355 347 317Z" stroke-width="8"/>
|
|
9
|
+
<path d="M411 222 C444 170 470 128 474 76 C513 143 516 203 481 257Z" stroke-width="3"/>
|
|
10
|
+
</g>
|
|
11
|
+
<path d="M156 285 C254 181 393 167 486 278" fill="none" stroke="#b7d7c2" stroke-width="42" opacity=".13" filter="url(#blur)"/>
|
|
12
|
+
<g fill="#f0f7d8"><circle cx="417" cy="231" r="6"/><circle cx="442" cy="239" r="4"/></g>
|
|
13
|
+
<g fill="none" stroke="#98bfa9" opacity=".38" stroke-linecap="round"><path d="M185 240 C133 218 97 230 56 261" stroke-width="12"/><path d="M228 206 C169 157 116 163 66 182" stroke-width="7"/><path d="M475 286 C532 278 566 296 609 332" stroke-width="9"/></g>
|
|
14
|
+
<text x="32" y="46" fill="#e4f1e9" font-family="system-ui" font-size="18" letter-spacing="3">SILHOUETTE STUDY B</text>
|
|
15
|
+
</svg>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// A UI-independent HTTP client. Persistence, hierarchy, and lifecycle are service concerns.
|
|
2
|
+
export class ThreadroomClient {
|
|
3
|
+
constructor(baseUrl = '') { this.baseUrl = baseUrl.replace(/\/$/, ''); }
|
|
4
|
+
url(path) { return `${this.baseUrl}${path}`; }
|
|
5
|
+
async request(path, options = {}) {
|
|
6
|
+
const response = await fetch(this.url(path), { ...options,
|
|
7
|
+
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) } });
|
|
8
|
+
const result = await response.json().catch(() => ({}));
|
|
9
|
+
if (!response.ok) throw new Error(result.error || `Request failed (${response.status})`);
|
|
10
|
+
return result;
|
|
11
|
+
}
|
|
12
|
+
tree() { return this.request('/api/tree'); }
|
|
13
|
+
read(id) { return this.request(`/api/nodes/${encodeURIComponent(id)}`); }
|
|
14
|
+
publish(input, key) { return this.request('/api/nodes', { method: 'POST',
|
|
15
|
+
headers: key ? { 'Idempotency-Key': key } : {}, body: JSON.stringify(input) }); }
|
|
16
|
+
ask(input, key) { return this.request('/api/ask', { method: 'POST',
|
|
17
|
+
headers: key ? { 'Idempotency-Key': key } : {}, body: JSON.stringify(input) }); }
|
|
18
|
+
respond(id, input, key) { return this.request(`/api/nodes/${encodeURIComponent(id)}/respond`, { method: 'POST',
|
|
19
|
+
headers: key ? { 'Idempotency-Key': key } : {}, body: JSON.stringify(input) }); }
|
|
20
|
+
reply(id, input, key) { return this.respond(id, input, key); }
|
|
21
|
+
subscribe(onEvent, { after = 0, onState = () => {} } = {}) {
|
|
22
|
+
const stream = new EventSource(this.url(`/api/stream?after=${after}`));
|
|
23
|
+
stream.addEventListener('open', () => onState('live'));
|
|
24
|
+
stream.addEventListener('error', () => onState('reconnecting'));
|
|
25
|
+
stream.addEventListener('change', (event) => {
|
|
26
|
+
try { onEvent(JSON.parse(event.data)); } catch (error) { console.error('Bad Threadroom event:', error); }
|
|
27
|
+
});
|
|
28
|
+
return () => stream.close();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
<meta name="theme-color" content="#f4f3ee">
|
|
7
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='12' fill='none' stroke='%23315d49' stroke-width='3'/%3E%3Cpath d='M8 16h16M16 8v16' stroke='%23315d49' stroke-width='2'/%3E%3C/svg%3E">
|
|
8
|
+
<title>Threadroom</title>
|
|
9
|
+
<link rel="stylesheet" href="/styles.css">
|
|
10
|
+
<script type="module" src="/app.js"></script>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<div class="app-shell">
|
|
14
|
+
<header class="topbar">
|
|
15
|
+
<a class="brand" href="/" aria-label="Threadroom home"><span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span><span>Threadroom</span></a>
|
|
16
|
+
<div class="topbar-center"><span class="live-dot" id="connectionDot"></span><span id="connectionLabel">Connecting to the conversation service</span><span class="spike-label">LOCAL SPIKE</span></div>
|
|
17
|
+
<div class="topbar-actions"><button class="icon-button" id="refreshButton" aria-label="Refresh threads">↻</button><button class="button button-dark" id="newThreadButton">+ Add node</button><div class="avatar" title="Scott">SM</div></div>
|
|
18
|
+
</header>
|
|
19
|
+
<main class="workspace">
|
|
20
|
+
<aside class="sidebar">
|
|
21
|
+
<nav aria-label="Thread views">
|
|
22
|
+
<p class="nav-label">Workspace</p>
|
|
23
|
+
<button class="nav-item active" data-view="outline"><span class="nav-icon">≡</span><span>Everything</span></button>
|
|
24
|
+
<button class="nav-item" data-view="outstanding"><span class="nav-icon">◇</span><span>Needs your answer</span><b id="needsCount">0</b></button>
|
|
25
|
+
<button class="nav-item" data-view="waiting_on_team"><span class="nav-icon">↗</span><span>Waiting on team</span><b id="teamCount">0</b></button>
|
|
26
|
+
<button class="nav-item" data-view="deferred"><span class="nav-icon">◷</span><span>Deferred</span><b id="deferredCount">0</b></button>
|
|
27
|
+
</nav>
|
|
28
|
+
<div class="outline-guide"><strong>No fixed levels.</strong><p>Expand with the chevron.<br>Zoom with the bullet.<br>Branch from any thread.</p></div>
|
|
29
|
+
<footer class="sidebar-footer"><span class="persistence-icon">▣</span><span><strong>Backend owns the record</strong><small>Independent HTTP API + live stream</small></span><span class="status-light"></span></footer>
|
|
30
|
+
</aside>
|
|
31
|
+
<section class="thread-index" aria-label="Recursive thread outline">
|
|
32
|
+
<div class="index-heading"><div><p class="eyebrow" id="viewEyebrow">OUTLINE</p><h1 id="viewTitle">Everything</h1></div><button class="filter-button" id="outlineUp" title="Zoom out" aria-label="Zoom out">↑</button></div>
|
|
33
|
+
<div class="outline-crumbs" id="outlineCrumbs"></div>
|
|
34
|
+
<label class="search-box"><span>⌕</span><input id="searchInput" type="search" aria-label="Search threads" placeholder="Search this branch" autocomplete="off"></label>
|
|
35
|
+
<div class="thread-list outline-list" id="threadList"></div>
|
|
36
|
+
<div class="outline-bottom"><button id="addInBranch" class="button button-ghost">+ Add here</button><span>Every item can have children</span></div>
|
|
37
|
+
</section>
|
|
38
|
+
<section class="thread-room" id="threadRoom" aria-live="polite"><div class="thread-loading"><span class="spinner"></span>Opening the conversation…</div></section>
|
|
39
|
+
</main>
|
|
40
|
+
</div>
|
|
41
|
+
<dialog id="newThreadDialog">
|
|
42
|
+
<form method="dialog" class="dialog-card" id="newThreadForm">
|
|
43
|
+
<div class="dialog-heading"><div><p class="eyebrow">ONE CALL, ANY DEPTH</p><h2>Add a node</h2></div><button class="dialog-close" value="cancel" formnovalidate aria-label="Close">×</button></div>
|
|
44
|
+
<p class="dialog-copy" id="publishContext">In Everything</p>
|
|
45
|
+
<label>Title<input name="title" placeholder="What belongs in this branch?" required></label>
|
|
46
|
+
<label>Content<textarea name="body" rows="4" placeholder="Whatever context you want to bring"></textarea></label>
|
|
47
|
+
<label class="checkbox-label"><input type="checkbox" name="expectsAnswer"> Ask for an answer</label>
|
|
48
|
+
<details class="authoring-details"><summary>Author an interactive canvas</summary><label>Self-contained HTML/CSS/JS<textarea name="html" rows="5" placeholder="AIs can publish any authored document through the API"></textarea></label><label>Readable historical fallback<textarea name="fallback" rows="2" placeholder="Explain what this document shows without needing its scripts"></textarea></label></details>
|
|
49
|
+
<div class="dialog-actions"><button class="button button-ghost" value="cancel" formnovalidate>Cancel</button><button class="button button-dark" value="default">Publish</button></div>
|
|
50
|
+
</form>
|
|
51
|
+
</dialog>
|
|
52
|
+
<div class="toast" id="toast" role="status"></div>
|
|
53
|
+
</body>
|
|
54
|
+
</html>
|