Add draft archive and acceptance workflow

This commit is contained in:
Joey Grasty
2026-08-23 15:47:46 -05:00
parent 23e4ef595e
commit 630c0c468f
18 changed files with 1701 additions and 67 deletions

View File

@@ -1,4 +1,9 @@
let project;
let initialDraft = '';
let generatedPackage = '';
let generatedModel = '';
let workingUuid = '';
let acceptedSceneUuid = '';
const byId = (id) => document.getElementById(id);
const selected = (container) => [...container.querySelectorAll('input:checked')].map((input) => input.value);
@@ -21,18 +26,18 @@ function updateSceneState() {
function render(data) {
project = data;
byId('status').textContent = data.locked ? 'Scrivener lock detected — read-only' : 'Project not locked — app remains read-only';
const priorSceneUuid = acceptedSceneUuid || byId('scene').value;
byId('status').textContent = data.locked ? 'Scrivener lock detected — archive and manuscript writes are disabled' : 'Project closed — accepted-draft writes are available';
byId('status').className = `status ${data.locked ? 'warning' : 'ok'}`;
byId('project-check').innerHTML = [
['Scenes', data.scenes.length],
['Briefs without scene', data.unmatched_briefs.length],
['Scenes without brief', data.unmatched_scenes.length],
['Characters', data.characters.length],
['Places', data.places.length],
['Scenes', data.scenes.length], ['Briefs without scene', data.unmatched_briefs.length],
['Scenes without brief', data.unmatched_scenes.length], ['Characters', data.characters.length], ['Places', data.places.length],
].map(([label, value]) => `<dt>${label}</dt><dd>${value}</dd>`).join('');
byId('scene').innerHTML = data.scenes.map((scene) => `<option value="${scene.uuid}">${escapeHtml(scene.title)}${escapeHtml(scene.chapter)}</option>`).join('');
if ([...byId('scene').options].some((option) => option.value === priorSceneUuid)) byId('scene').value = priorSceneUuid;
checks(byId('characters'), data.characters);
checks(byId('places'), data.places);
byId('world-notes').textContent = data.world_notes || 'No project-level World Notes recorded.';
updateSceneState();
}
@@ -42,31 +47,87 @@ async function load() {
render(await response.json());
}
async function build() {
byId('error').textContent = '';
const payload = {
scene_uuid: byId('scene').value,
character_uuids: selected(byId('characters')),
place_uuids: selected(byId('places')),
lexicon: byId('lexicon').value,
story: byId('story').value,
recall: byId('recall').value,
previous: byId('previous').value,
notes: byId('notes').value,
directive: byId('directive').value,
new_thread: byId('new-thread').checked,
};
const response = await fetch('/api/package-preview', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
});
function payload() {
return { scene_uuid: byId('scene').value, character_uuids: selected(byId('characters')), place_uuids: selected(byId('places')), lexicon: byId('lexicon').value, story: byId('story').value, recall: byId('recall').value, previous: byId('previous').value, notes: byId('notes').value, directive: byId('directive').value, new_thread: byId('new-thread').checked };
}
async function request(path, body) {
const response = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const data = await response.json();
if (!response.ok) {
byId('error').textContent = data.error || 'Could not build package.';
return;
}
byId('package').textContent = data.package;
if (!response.ok) throw new Error(data.error || 'The request could not be completed.');
return data;
}
async function build() {
byId('error').textContent = ''; byId('warnings').textContent = '';
try {
const data = await request('/api/package-preview', payload());
generatedPackage = data.package;
byId('package').textContent = data.package;
byId('warnings').textContent = (data.warnings || []).join(' ');
} catch (error) { byId('error').textContent = error.message; }
}
function resetDraftState(message = 'An outright rejection writes nothing to Scrivener.') {
initialDraft = ''; generatedPackage = ''; generatedModel = ''; workingUuid = ''; acceptedSceneUuid = '';
byId('working-draft').value = ''; byId('working-draft').disabled = true;
byId('accept-confirmation').checked = false; byId('final-confirmation').checked = false;
['accept-editing', 'reject-draft', 'save-working', 'accept-book'].forEach((id) => { byId(id).disabled = true; });
byId('archive-status').textContent = message;
}
async function generateDraft() {
byId('error').textContent = '';
try {
const data = await request('/api/draft', { ...payload(), send_confirmation: byId('send-confirmation').checked, max_tokens: 3000 });
initialDraft = data.draft; generatedModel = data.model;
const preview = await request('/api/package-preview', payload());
generatedPackage = preview.package;
byId('working-draft').value = data.draft;
byId('working-draft').disabled = true;
byId('accept-editing').disabled = false; byId('reject-draft').disabled = false;
byId('archive-status').textContent = 'This generated text is not yet saved in Scrivener.';
} catch (error) { byId('error').textContent = error.message; }
}
async function acceptForEditing() {
byId('error').textContent = '';
try {
const data = await request('/api/accept-for-editing', { ...payload(), draft: initialDraft, model: generatedModel, accept_confirmation: byId('accept-confirmation').checked });
workingUuid = data.working_uuid;
acceptedSceneUuid = byId('scene').value;
byId('working-draft').value = initialDraft;
byId('working-draft').disabled = false;
byId('accept-editing').disabled = true; byId('reject-draft').disabled = true;
byId('save-working').disabled = false; byId('accept-book').disabled = false;
byId('archive-status').textContent = 'Initial draft and exact package saved. You may now edit the working copy.';
await load();
} catch (error) { byId('error').textContent = error.message; }
}
async function saveWorkingCopy() {
byId('error').textContent = '';
try {
await request('/api/save-working-copy', { working_uuid: workingUuid, text: byId('working-draft').value });
byId('archive-status').textContent = 'Working copy saved in Scrivener.';
} catch (error) { byId('error').textContent = error.message; }
}
async function acceptIntoBook() {
byId('error').textContent = '';
try {
await request('/api/save-working-copy', { working_uuid: workingUuid, text: byId('working-draft').value });
await request('/api/accept-into-book', { scene_uuid: acceptedSceneUuid, text: byId('working-draft').value, final_confirmation: byId('final-confirmation').checked });
byId('archive-status').textContent = 'Working copy saved and canonical prose written into Manuscript.';
await load();
} catch (error) { byId('error').textContent = error.message; }
}
byId('scene').addEventListener('change', updateSceneState);
byId('build').addEventListener('click', build);
byId('draft').addEventListener('click', generateDraft);
byId('accept-editing').addEventListener('click', acceptForEditing);
byId('reject-draft').addEventListener('click', () => resetDraftState('Draft rejected and discarded. Nothing was written to Scrivener.'));
byId('save-working').addEventListener('click', saveWorkingCopy);
byId('accept-book').addEventListener('click', acceptIntoBook);
load().catch((error) => { byId('status').textContent = error.message; byId('status').className = 'status warning'; });

View File

@@ -9,7 +9,7 @@
<body>
<header>
<div>
<p class="eyebrow">READ-ONLY PROTOTYPE</p>
<p class="eyebrow">LOCAL PROTOTYPE</p>
<h1>Package Preview</h1>
</div>
<div id="status" class="status">Loading project…</div>
@@ -32,6 +32,10 @@
<h2>Author BRIEF <span class="read-only">read-only</span></h2>
<pre id="brief">Select a scene to view its paired BRIEF.</pre>
</section>
<section class="brief-panel">
<h2>World Notes <span class="read-only">read-only reference</span></h2>
<pre id="world-notes">Loading World Notes…</pre>
</section>
<div class="grid">
<label>LEXICON (manual subset)
<textarea id="lexicon" rows="4" placeholder="Term — usage note"></textarea>
@@ -54,9 +58,25 @@
</div>
<label class="checkbox"><input id="new-thread" type="checkbox" checked> Treat empty STORY/PREVIOUS as a new narrative thread</label>
<button id="build">Build package preview</button>
<label class="checkbox"><input id="send-confirmation" type="checkbox"> I understand this sends the package to OpenRouter.</label>
<button id="draft">Generate temporary draft — no Scrivener write</button>
<p id="error" class="error"></p>
<p id="warnings" class="warnings"></p>
<h2>Exact package</h2>
<pre id="package">Select a scene, choose context, then build a preview.</pre>
<h2>Temporary draft</h2>
<textarea id="working-draft" rows="20" disabled placeholder="A generated draft will appear here."></textarea>
<div class="draft-actions">
<label class="checkbox"><input id="accept-confirmation" type="checkbox"> I accept this initial draft for editing and archival.</label>
<button id="accept-editing" disabled>Accept draft for editing</button>
<button id="reject-draft" disabled>Reject draft — discard it</button>
<button id="save-working" disabled>Save working copy</button>
</div>
<p id="archive-status" class="muted">An outright rejection writes nothing to Scrivener.</p>
<div class="final-actions">
<label class="checkbox"><input id="final-confirmation" type="checkbox"> I confirm the edited working copy is the canonical scene.</label>
<button id="accept-book" disabled>Accept working copy into Manuscript</button>
</div>
</section>
</main>
<script src="/static/app.js"></script>

View File

@@ -8,7 +8,10 @@ aside { padding: 1.2rem 1.5rem; border-right: 1px solid #d8d2c5; background: #fc
label { display: block; font-size: .85rem; font-weight: 650; } select, textarea { width: 100%; margin-top: .35rem; border: 1px solid #bab2a5; border-radius: 4px; background: #fffefb; padding: .55rem; font: .9rem ui-monospace, SFMono-Regular, Menlo, monospace; }
select { font-family: inherit; } .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; } .checks label { padding: .2rem 0; font-weight: 400; } .checks input { margin-right: .35rem; }
button { margin: 1rem 0; border: 0; border-radius: 4px; padding: .65rem 1rem; background: #b54c2d; color: white; font-weight: 700; cursor: pointer; } button:hover { background: #923b22; }
button:disabled { cursor: not-allowed; opacity: .5; } .draft-actions, .final-actions { margin-top: .5rem; padding: .75rem 1rem; border: 1px solid #d8d2c5; background: #fcfaf5; } .draft-actions button, .final-actions button { margin: .6rem .5rem 0 0; }
#working-draft { min-height: 28rem; max-height: 65vh; resize: vertical; background: #fffefb; color: #17212b; font: .9rem/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
pre { min-height: 28rem; max-height: 65vh; overflow: auto; margin: 0; padding: 1rem; border: 1px solid #d8d2c5; background: #202525; color: #e5f3ed; white-space: pre-wrap; font: .82rem/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
.brief-panel { margin-bottom: 1.25rem; padding: 0 1rem 1rem; border: 1px solid #d8d2c5; background: #fcfaf5; } .brief-panel h2 { margin-top: 1rem; } .brief-panel pre { min-height: 0; max-height: 16rem; background: #fffefb; color: #17212b; font-family: inherit; font-size: .95rem; } .read-only { font-size: .68rem; font-weight: 600; color: #665f54; text-transform: uppercase; letter-spacing: .08em; }
dl { display: grid; grid-template-columns: 1fr auto; margin: 0; font-size: .9rem; } dt, dd { margin: 0; padding: .25rem 0; border-bottom: 1px solid #ece6da; } dd { font-weight: 700; } .status { border-radius: 999px; padding: .45rem .75rem; font-size: .78rem; } .ok { background: #d9eee4; color: #164d38; } .warning { background: #ffe2d6; color: #7f2c19; } .muted { color: #665f54; font-size: .8rem; } .error { color: #aa2717; font-weight: 700; } .checkbox { margin-top: 1rem; font-weight: 400; }
.warnings { color: #805215; font-weight: 650; }
@media (max-width: 800px) { main { grid-template-columns: 1fr; } aside { border-right: 0; border-bottom: 1px solid #d8d2c5; } .grid { grid-template-columns: 1fr; } }