Add continuity dashboard and scene authoring workflow
This commit is contained in:
264
prototype/static/app.js
vendored
264
prototype/static/app.js
vendored
@@ -7,6 +7,7 @@ let acceptedSceneUuid = '';
|
||||
let summarySceneUuid = '';
|
||||
let contextSceneUuid = '';
|
||||
let updatesSceneUuid = '';
|
||||
let hasLoadedProject = false;
|
||||
|
||||
const byId = (id) => document.getElementById(id);
|
||||
const selected = (container) => [...container.querySelectorAll('input:checked')].map((input) => input.value);
|
||||
@@ -40,18 +41,68 @@ function paragraphTail(text, targetWords) {
|
||||
return tail.join('\n\n');
|
||||
}
|
||||
|
||||
function threadChainFrom(sceneUuid) {
|
||||
const chain = [];
|
||||
const seen = new Set();
|
||||
let cursor = sceneByUuid(sceneUuid);
|
||||
while (cursor && !seen.has(cursor.uuid)) {
|
||||
chain.push(cursor);
|
||||
seen.add(cursor.uuid);
|
||||
cursor = cursor.predecessor_uuid ? sceneByUuid(cursor.predecessor_uuid) : null;
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
function updateThreadStatus() {
|
||||
const startsNew = byId('new-thread').checked;
|
||||
byId('thread-predecessor').disabled = startsNew;
|
||||
const source = sceneByUuid(byId('thread-predecessor').value);
|
||||
if (startsNew) {
|
||||
byId('thread-status').textContent = 'This scene will start a new narrative thread when it is accepted into the book.';
|
||||
return;
|
||||
}
|
||||
if (!source) {
|
||||
byId('thread-status').textContent = 'Choose a canonical scene to continue, or explicitly start a new thread.';
|
||||
return;
|
||||
}
|
||||
byId('thread-status').textContent = `When accepted into the book, this scene will continue from ${source.title}.`;
|
||||
}
|
||||
|
||||
function loadStory() {
|
||||
const selectedSummaries = [...byId('story-summaries').querySelectorAll('input:checked')]
|
||||
.map((input) => sceneByUuid(input.value)).filter(Boolean);
|
||||
byId('story').value = selectedSummaries.map((scene) => scene.summary).join('\n\n');
|
||||
byId('new-thread').checked = selectedSummaries.length === 0;
|
||||
}
|
||||
|
||||
function loadPrevious() {
|
||||
const source = sceneByUuid(byId('previous-source').value);
|
||||
if (!source) { byId('previous').value = ''; return; }
|
||||
byId('previous').value = paragraphTail(source.text, Number(byId('previous-words').value) || 400);
|
||||
byId('new-thread').checked = false;
|
||||
}
|
||||
|
||||
function setLexiconSelection(value) {
|
||||
const selectedText = value || '';
|
||||
byId('lexicon-entries').querySelectorAll('input').forEach((input) => {
|
||||
const entry = project.lexicon_entries[Number(input.value)];
|
||||
input.checked = Boolean(entry && selectedText.includes(entry.text));
|
||||
});
|
||||
}
|
||||
|
||||
function loadLexicon() {
|
||||
const entries = [...byId('lexicon-entries').querySelectorAll('input:checked')]
|
||||
.map((input) => project.lexicon_entries[Number(input.value)]).filter(Boolean);
|
||||
byId('lexicon').value = entries.map((entry) => entry.text).join('\n\n');
|
||||
byId('lexicon-status').textContent = entries.length ? `${entries.length} selected lexicon entr${entries.length === 1 ? 'y' : 'ies'} loaded. You may still edit the package text.` : 'No lexicon entries selected; LEXICON will be empty.';
|
||||
}
|
||||
|
||||
function clearLexicon() {
|
||||
byId('lexicon-entries').querySelectorAll('input').forEach((input) => { input.checked = false; });
|
||||
byId('lexicon').value = '';
|
||||
byId('lexicon-status').textContent = 'Lexicon selection cleared for this package.';
|
||||
}
|
||||
|
||||
function renderLexiconEntries(entries) {
|
||||
byId('lexicon-entries').innerHTML = entries.map((entry, index) => `<label title="${escapeHtml(entry.text)}"><input type="checkbox" value="${index}"> ${escapeHtml(entry.title)}</label>`).join('') || '<p class="muted">No Term — definition entries are available in Master Lexicon.</p>';
|
||||
}
|
||||
|
||||
function priorPackageContext() {
|
||||
@@ -95,17 +146,61 @@ function populateContinuity(defaults = false) {
|
||||
const withSummaries = earlier.filter((candidate) => candidate.summary);
|
||||
const latest = earlier.at(-1);
|
||||
byId('story-summaries').innerHTML = withSummaries.map((candidate) => `<label><input type="checkbox" value="${candidate.uuid}"> ${escapeHtml(candidate.title)} — ${escapeHtml(candidate.summary)}</label>`).join('') || '<p class="muted">No earlier approved scene summaries are available.</p>';
|
||||
setOptions('previous-source', earlier, 'None — new narrative thread');
|
||||
setOptions('previous-source', earlier, 'None — no verbatim previous text');
|
||||
setOptions('thread-predecessor', earlier, 'Choose a canonical scene');
|
||||
setOptions('recall-source', project.scenes.filter((candidate) => candidate.state === 'canonical' && candidate.uuid !== scene.uuid), 'Choose a canonical scene to recall');
|
||||
if (latest) byId('previous-source').value = latest.uuid;
|
||||
if (latest && latest.summary) {
|
||||
if (latest) {
|
||||
byId('previous-source').value = latest.uuid;
|
||||
byId('thread-predecessor').value = latest.uuid;
|
||||
const check = byId('story-summaries').querySelector(`input[value="${latest.uuid}"]`);
|
||||
if (check) check.checked = true;
|
||||
}
|
||||
if (defaults) {
|
||||
loadStory();
|
||||
loadPrevious();
|
||||
if (latest && latest.package_context.character_uuids.length) carryCharacters();
|
||||
const context = scene.package_context;
|
||||
const hasSavedPackage = Boolean(context && (context.directive || context.story || context.previous || context.recall || context.lexicon || context.notes || context.character_uuids.length));
|
||||
if (hasSavedPackage) {
|
||||
const characterUuids = new Set(context.character_uuids);
|
||||
const placeUuids = new Set(context.place_uuids || []);
|
||||
byId('characters').querySelectorAll('input').forEach((input) => { input.checked = characterUuids.has(input.value); });
|
||||
byId('places').querySelectorAll('input').forEach((input) => { input.checked = placeUuids.has(input.value); });
|
||||
byId('lexicon').value = context.lexicon === '(none)' ? '' : context.lexicon;
|
||||
byId('story').value = context.story === '(none)' || context.story === '(none - new storyline)' ? '' : context.story;
|
||||
byId('recall').value = context.recall === '(none)' ? '' : context.recall;
|
||||
byId('previous').value = context.previous === '(none)' ? '' : context.previous;
|
||||
byId('notes').value = context.notes === '(none)' ? '' : context.notes;
|
||||
byId('directive').value = context.directive === '(none)' ? '' : context.directive;
|
||||
byId('package').textContent = context.exact_package || 'No archived package is available for this scene.';
|
||||
setLexiconSelection(byId('lexicon').value);
|
||||
byId('story-summaries').querySelectorAll('input').forEach((input) => {
|
||||
const candidate = sceneByUuid(input.value);
|
||||
input.checked = Boolean(candidate?.summary && context.story.includes(candidate.summary));
|
||||
});
|
||||
const previousSource = earlier.find((candidate) => context.previous && candidate.text.trim().endsWith(context.previous.trim()));
|
||||
if (previousSource) byId('previous-source').value = previousSource.uuid;
|
||||
} else {
|
||||
byId('package').textContent = 'No archived package is available for this scene.';
|
||||
const carriedLexicon = latest?.package_context.lexicon;
|
||||
if (carriedLexicon && carriedLexicon !== '(none)') {
|
||||
byId('lexicon').value = carriedLexicon;
|
||||
setLexiconSelection(carriedLexicon);
|
||||
byId('lexicon-status').textContent = 'Lexicon selection carried forward from the latest canonical scene; adjust it for this package.';
|
||||
} else {
|
||||
byId('lexicon').value = '';
|
||||
setLexiconSelection('');
|
||||
}
|
||||
loadStory();
|
||||
loadPrevious();
|
||||
if (latest && latest.package_context.character_uuids.length) carryCharacters();
|
||||
}
|
||||
if (scene.state === 'canonical' && scene.thread_state === 'new-thread') {
|
||||
byId('new-thread').checked = true;
|
||||
} else if (scene.state === 'canonical' && scene.predecessor_uuid) {
|
||||
byId('new-thread').checked = false;
|
||||
byId('thread-predecessor').value = scene.predecessor_uuid;
|
||||
} else {
|
||||
byId('new-thread').checked = !latest;
|
||||
}
|
||||
updateThreadStatus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +208,39 @@ function escapeHtml(value) {
|
||||
return value.replace(/[&<>'"]/g, (character) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[character]));
|
||||
}
|
||||
|
||||
function renderDashboard() {
|
||||
const canonical = project.scenes.filter((scene) => scene.state === 'canonical');
|
||||
const byUuid = new Map(canonical.map((scene) => [scene.uuid, scene]));
|
||||
const children = new Map();
|
||||
canonical.forEach((scene) => {
|
||||
if (scene.predecessor_uuid && byUuid.has(scene.predecessor_uuid)) {
|
||||
children.set(scene.predecessor_uuid, [...(children.get(scene.predecessor_uuid) || []), scene]);
|
||||
}
|
||||
});
|
||||
const roots = canonical.filter((scene) => !scene.predecessor_uuid || !byUuid.has(scene.predecessor_uuid));
|
||||
function treeNode(scene, ancestry = new Set()) {
|
||||
const isCycle = ancestry.has(scene.uuid);
|
||||
const state = scene.thread_state === 'new-thread' ? 'thread start' : 'continues';
|
||||
const childNodes = isCycle ? [] : (children.get(scene.uuid) || []);
|
||||
const nextAncestry = new Set(ancestry); nextAncestry.add(scene.uuid);
|
||||
return `<li><button class="scene-jump" data-scene="${scene.uuid}">${escapeHtml(scene.title)}</button><span class="tree-state">${state}</span>${childNodes.length ? `<ul>${childNodes.map((child) => treeNode(child, nextAncestry)).join('')}</ul>` : ''}</li>`;
|
||||
}
|
||||
byId('thread-chains').innerHTML = roots.length ? `<div class="thread-tree"><p class="muted">Narrative threads</p><ul>${roots.map((root) => treeNode(root)).join('')}</ul></div>` : '<p class="muted">No canonical scenes yet.</p>';
|
||||
byId('continuity-dashboard').innerHTML = `<table><thead><tr><th>Scene</th><th>Chapter</th><th>Thread link</th><th>Approved summary</th></tr></thead><tbody>${canonical.map((scene) => {
|
||||
const predecessor = byUuid.get(scene.predecessor_uuid);
|
||||
const link = scene.thread_state === 'new-thread' ? 'starts a thread' : predecessor ? `continues from ${predecessor.title}` : 'not assigned';
|
||||
const summary = scene.summary ? scene.summary.slice(0, 180) + (scene.summary.length > 180 ? '…' : '') : '—';
|
||||
return `<tr><td><button class="scene-jump" data-scene="${scene.uuid}">${escapeHtml(scene.title)}</button></td><td>${escapeHtml(scene.chapter)}</td><td>${escapeHtml(link)}</td><td>${escapeHtml(summary)}</td></tr>`;
|
||||
}).join('')}</tbody></table>`;
|
||||
}
|
||||
|
||||
function selectScene(uuid) {
|
||||
if ([...byId('scene').options].some((option) => option.value === uuid)) {
|
||||
byId('scene').value = uuid;
|
||||
changeScene();
|
||||
}
|
||||
}
|
||||
|
||||
function updateSceneState() {
|
||||
const scene = project.scenes.find((candidate) => candidate.uuid === byId('scene').value);
|
||||
const binding = scene?.binding_state === 'stable' ? 'stable UUID binding' : scene?.binding_state === 'legacy-title-match' ? 'legacy title match' : 'BRIEF missing';
|
||||
@@ -121,16 +249,43 @@ function updateSceneState() {
|
||||
byId('brief').disabled = project.locked;
|
||||
byId('save-brief').disabled = project.locked || !scene;
|
||||
byId('brief-status').textContent = project.locked ? 'Close Scrivener before saving a BRIEF.' : scene?.brief_uuid ? 'Editing the paired BRIEF.' : 'Saving will create and stably bind a new BRIEF for this scene.';
|
||||
byId('save-thread-link').disabled = project.locked || !(scene && scene.state === 'canonical');
|
||||
byId('propose-summary').disabled = !(scene && scene.state === 'canonical');
|
||||
byId('propose-updates').disabled = !(scene && scene.state === 'canonical');
|
||||
}
|
||||
|
||||
function changeScene() {
|
||||
const sceneChanged = contextSceneUuid !== byId('scene').value;
|
||||
const keepsActiveWorkingDraft = workingUuid && acceptedSceneUuid === byId('scene').value;
|
||||
if (sceneChanged && !keepsActiveWorkingDraft) resetDraftState('Select a scene to begin drafting, or review its stored authoring context below.');
|
||||
updateSceneState();
|
||||
if (contextSceneUuid !== byId('scene').value) {
|
||||
if (sceneChanged) {
|
||||
contextSceneUuid = byId('scene').value;
|
||||
populateContinuity(true);
|
||||
}
|
||||
const scene = sceneByUuid(byId('scene').value);
|
||||
if (scene?.state === 'canonical') {
|
||||
byId('working-draft').value = scene.text;
|
||||
byId('working-draft').disabled = true;
|
||||
byId('archive-status').textContent = 'Canonical Manuscript text is shown read-only. Edit the scene in Scrivener; this page restores its saved drafting context.';
|
||||
summarySceneUuid = scene.uuid;
|
||||
byId('scene-summary').value = scene.summary;
|
||||
byId('scene-summary').disabled = false;
|
||||
byId('save-summary').disabled = !scene.summary;
|
||||
byId('summary-status').textContent = scene.summary ? 'Approved summary loaded; revise it here and confirm before saving.' : 'No approved summary yet.';
|
||||
} else if (scene?.working_copy) {
|
||||
workingUuid = scene.working_copy.uuid;
|
||||
acceptedSceneUuid = scene.uuid;
|
||||
byId('working-draft').value = scene.working_copy.text;
|
||||
byId('working-draft').disabled = false;
|
||||
byId('accept-confirmation').checked = false;
|
||||
byId('final-confirmation').checked = 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 = 'Saved working copy restored from Accepted for Editing. You may continue editing or accept it into Manuscript.';
|
||||
}
|
||||
if (summarySceneUuid && summarySceneUuid !== byId('scene').value) {
|
||||
summarySceneUuid = '';
|
||||
byId('scene-summary').value = '';
|
||||
@@ -143,23 +298,39 @@ function changeScene() {
|
||||
|
||||
function render(data) {
|
||||
project = data;
|
||||
const priorSceneUuid = acceptedSceneUuid || byId('scene').value;
|
||||
const canonicalScenes = data.scenes.filter((scene) => scene.state === 'canonical');
|
||||
const priorSceneUuid = acceptedSceneUuid || (hasLoadedProject ? byId('scene').value : canonicalScenes.at(-1)?.uuid || data.scenes.at(-1)?.uuid);
|
||||
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'}`;
|
||||
const plannedScenes = data.scenes.filter((scene) => scene.state === 'planned');
|
||||
const approvedSummaries = canonicalScenes.filter((scene) => scene.summary.trim()).length;
|
||||
const threadStarts = canonicalScenes.filter((scene) => scene.thread_state === 'new-thread').length;
|
||||
const threadContinuations = canonicalScenes.filter((scene) => scene.thread_state === 'continues').length;
|
||||
byId('project-check').innerHTML = [
|
||||
['Scenes', data.scenes.length], ['Briefs without scene', data.unmatched_briefs.length],
|
||||
['Scenes without brief', data.unmatched_scenes.length], ['Stable scene bindings', data.binding_counts.stable], ['Legacy title matches', data.binding_counts.legacy], ['Characters', data.characters.length], ['Places', data.places.length],
|
||||
['Canonical scenes', canonicalScenes.length], ['Planned scenes', plannedScenes.length],
|
||||
['Approved dense summaries', `${approvedSummaries} of ${canonicalScenes.length}`],
|
||||
['Narrative thread starts', threadStarts], ['Thread continuations', threadContinuations],
|
||||
['Characters', data.characters.length], ['Places', data.places.length],
|
||||
['Scenes without BRIEF', data.unmatched_scenes.length], ['Briefs without scene', data.unmatched_briefs.length],
|
||||
['Stable scene bindings', data.binding_counts.stable], ['Legacy title matches', data.binding_counts.legacy],
|
||||
].map(([label, value]) => `<dt>${label}</dt><dd>${value}</dd>`).join('');
|
||||
byId('reconcile-bindings').disabled = data.locked;
|
||||
byId('binding-status').textContent = data.locked ? 'Close Scrivener before creating or updating bindings.' : data.binding_counts.legacy ? 'Create stable bindings before renaming or moving scenes.' : 'Scene/BRIEF bindings are stored by UUID.';
|
||||
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;
|
||||
byId('new-scene-chapter').innerHTML = data.chapters.map((chapter) => `<option value="${chapter.uuid}">${escapeHtml(chapter.title)}</option>`).join('');
|
||||
byId('new-scene-chapter').disabled = data.locked || data.chapters.length === 0;
|
||||
byId('new-scene-title').disabled = data.locked;
|
||||
byId('create-scene').disabled = data.locked || data.chapters.length === 0;
|
||||
byId('create-scene-status').textContent = data.locked ? 'Close Scrivener before creating a scene.' : 'Creates a blank Manuscript scene and a blank paired BRIEF.';
|
||||
checks(byId('characters'), data.characters);
|
||||
checks(byId('places'), data.places);
|
||||
renderLexiconEntries(data.lexicon_entries);
|
||||
byId('world-notes').textContent = data.world_notes || 'No project-level World Notes recorded.';
|
||||
updateSceneState();
|
||||
contextSceneUuid = byId('scene').value;
|
||||
populateContinuity(true);
|
||||
renderDashboard();
|
||||
contextSceneUuid = '';
|
||||
changeScene();
|
||||
hasLoadedProject = true;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
@@ -197,6 +368,34 @@ async function saveBrief() {
|
||||
} catch (error) { byId('error').textContent = error.message; }
|
||||
}
|
||||
|
||||
async function createScene() {
|
||||
byId('error').textContent = '';
|
||||
try {
|
||||
const data = await request('/api/create-scene', {
|
||||
chapter_uuid: byId('new-scene-chapter').value,
|
||||
title: byId('new-scene-title').value,
|
||||
});
|
||||
byId('new-scene-title').value = '';
|
||||
await load();
|
||||
selectScene(data.scene_uuid);
|
||||
byId('create-scene-status').textContent = `${data.title} and its paired blank BRIEF were created.`;
|
||||
} catch (error) { byId('error').textContent = error.message; }
|
||||
}
|
||||
|
||||
async function saveThreadLink() {
|
||||
byId('error').textContent = '';
|
||||
const predecessorUuid = byId('new-thread').checked ? null : byId('thread-predecessor').value || null;
|
||||
if (!byId('new-thread').checked && !predecessorUuid) {
|
||||
byId('error').textContent = 'Choose a canonical scene to continue, or explicitly start a new thread.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await request('/api/save-thread-link', { scene_uuid: byId('scene').value, predecessor_uuid: predecessorUuid });
|
||||
byId('thread-status').textContent = predecessorUuid ? 'Narrative-thread link saved.' : 'This scene is recorded as the start of a narrative thread.';
|
||||
await load();
|
||||
} catch (error) { byId('error').textContent = error.message; }
|
||||
}
|
||||
|
||||
async function build() {
|
||||
byId('error').textContent = ''; byId('warnings').textContent = '';
|
||||
try {
|
||||
@@ -255,8 +454,10 @@ async function saveWorkingCopy() {
|
||||
async function acceptIntoBook() {
|
||||
byId('error').textContent = '';
|
||||
try {
|
||||
const predecessorUuid = byId('new-thread').checked ? null : byId('thread-predecessor').value || null;
|
||||
if (!byId('new-thread').checked && !predecessorUuid) throw new Error('Choose a PREVIOUS source scene or explicitly start a new narrative thread.');
|
||||
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 });
|
||||
await request('/api/accept-into-book', { scene_uuid: acceptedSceneUuid, text: byId('working-draft').value, predecessor_uuid: predecessorUuid, final_confirmation: byId('final-confirmation').checked });
|
||||
byId('archive-status').textContent = 'Working copy saved and canonical prose written into Manuscript.';
|
||||
await load();
|
||||
byId('propose-summary').disabled = false;
|
||||
@@ -315,6 +516,8 @@ function renderNewCharacterUpdates(characters) {
|
||||
async function proposeUpdates() {
|
||||
byId('error').textContent = '';
|
||||
const scene = sceneByUuid(byId('scene').value);
|
||||
byId('updates-status').textContent = 'Requesting continuity proposals from OpenRouter…';
|
||||
byId('propose-updates').disabled = true;
|
||||
try {
|
||||
const data = await request('/api/propose-continuity-updates', { scene_uuid: scene.uuid, text: scene.text, send_confirmation: byId('updates-send-confirmation').checked });
|
||||
updatesSceneUuid = scene.uuid;
|
||||
@@ -326,7 +529,12 @@ async function proposeUpdates() {
|
||||
byId('world-note-additions').disabled = false;
|
||||
byId('save-updates').disabled = false;
|
||||
byId('updates-status').textContent = 'Each selected Character field is a complete replacement record: trim or revise it before saving. Uncheck to exclude it.';
|
||||
} catch (error) { byId('error').textContent = error.message; }
|
||||
} catch (error) {
|
||||
byId('error').textContent = error.message;
|
||||
byId('updates-status').textContent = `Continuity proposal failed: ${error.message}`;
|
||||
} finally {
|
||||
byId('propose-updates').disabled = !(sceneByUuid(byId('scene').value)?.state === 'canonical');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveUpdates() {
|
||||
@@ -344,8 +552,13 @@ async function saveUpdates() {
|
||||
byId('scene').addEventListener('change', changeScene);
|
||||
byId('reconcile-bindings').addEventListener('click', reconcileBindings);
|
||||
byId('save-brief').addEventListener('click', saveBrief);
|
||||
byId('create-scene').addEventListener('click', createScene);
|
||||
byId('save-thread-link').addEventListener('click', saveThreadLink);
|
||||
byId('load-story').addEventListener('click', loadStory);
|
||||
byId('load-previous').addEventListener('click', loadPrevious);
|
||||
byId('load-lexicon').addEventListener('click', loadLexicon);
|
||||
byId('clear-lexicon').addEventListener('click', clearLexicon);
|
||||
byId('thread-predecessor').addEventListener('change', updateThreadStatus);
|
||||
byId('add-recall').addEventListener('click', addRecall);
|
||||
byId('carry-characters').addEventListener('click', carryCharacters);
|
||||
byId('insert-prior-notes').addEventListener('click', () => appendNotes(priorPackageContext()?.notes || '', 'prior package NOTES'));
|
||||
@@ -359,10 +572,13 @@ byId('story-summaries').addEventListener('change', (event) => {
|
||||
});
|
||||
byId('new-thread').addEventListener('change', () => {
|
||||
if (byId('new-thread').checked) {
|
||||
byId('story').value = '';
|
||||
byId('previous').value = '';
|
||||
byId('story-summaries').querySelectorAll('input:checked').forEach((input) => { input.checked = false; });
|
||||
byId('previous-source').value = '';
|
||||
updateThreadStatus();
|
||||
} else if (!byId('thread-predecessor').value) {
|
||||
const latest = earlierCanonicalScenes(byId('scene').value).at(-1);
|
||||
if (latest) byId('thread-predecessor').value = latest.uuid;
|
||||
updateThreadStatus();
|
||||
} else {
|
||||
updateThreadStatus();
|
||||
}
|
||||
});
|
||||
byId('build').addEventListener('click', build);
|
||||
@@ -375,4 +591,10 @@ byId('propose-summary').addEventListener('click', proposeSummary);
|
||||
byId('save-summary').addEventListener('click', saveSummary);
|
||||
byId('propose-updates').addEventListener('click', proposeUpdates);
|
||||
byId('save-updates').addEventListener('click', saveUpdates);
|
||||
function selectDashboardScene(event) {
|
||||
const button = event.target.closest('.scene-jump');
|
||||
if (button) selectScene(button.dataset.scene);
|
||||
}
|
||||
byId('thread-chains').addEventListener('click', selectDashboardScene);
|
||||
byId('continuity-dashboard').addEventListener('click', selectDashboardScene);
|
||||
load().catch((error) => { byId('status').textContent = error.message; byId('status').className = 'status warning'; });
|
||||
|
||||
@@ -24,12 +24,27 @@
|
||||
<label for="scene">Paired Manuscript scene</label>
|
||||
<select id="scene"></select>
|
||||
<p id="scene-state" class="muted"></p>
|
||||
<section class="create-scene">
|
||||
<h2>Create scene</h2>
|
||||
<label for="new-scene-chapter">Manuscript chapter</label>
|
||||
<select id="new-scene-chapter"></select>
|
||||
<label for="new-scene-title">Scene label <span class="read-only">optional</span></label>
|
||||
<input id="new-scene-title" type="text" placeholder="Defaults to next Scene X">
|
||||
<button id="create-scene" type="button">Create blank scene and BRIEF</button>
|
||||
<p id="create-scene-status" class="muted"></p>
|
||||
</section>
|
||||
<h2>Characters</h2>
|
||||
<div id="characters" class="checks"></div>
|
||||
<h2>Places for NOTES</h2>
|
||||
<div id="places" class="checks"></div>
|
||||
</aside>
|
||||
<section class="workspace">
|
||||
<section class="dashboard-panel">
|
||||
<h2>Continuity dashboard</h2>
|
||||
<p class="muted">Canonical scenes and their author-directed narrative-thread links. Select a row to work with that scene.</p>
|
||||
<div id="thread-chains"></div>
|
||||
<div id="continuity-dashboard"></div>
|
||||
</section>
|
||||
<section class="brief-panel">
|
||||
<h2>Author BRIEF <span class="read-only">editable</span></h2>
|
||||
<textarea id="brief" rows="10" placeholder="Write the BRIEF that drives this scene."></textarea>
|
||||
@@ -42,7 +57,14 @@
|
||||
</section>
|
||||
<section class="continuity-panel">
|
||||
<h2>Continuity context</h2>
|
||||
<p class="muted">The default follows the latest earlier canonical scene. Change the selections freely for a different narrative thread.</p>
|
||||
<p class="muted">Thread membership and package context are independent author choices. The controls suggest the latest earlier canonical scene as a starting point.</p>
|
||||
<div class="thread-controls">
|
||||
<label class="checkbox"><input id="new-thread" type="checkbox" checked> Start a new narrative thread</label>
|
||||
<label for="thread-predecessor">Or continue an existing thread from</label>
|
||||
<select id="thread-predecessor"></select>
|
||||
<button id="save-thread-link" type="button">Save thread link for this canonical scene</button>
|
||||
<p id="thread-status" class="muted"></p>
|
||||
</div>
|
||||
<div class="continuity-grid">
|
||||
<div>
|
||||
<label>STORY summaries for this thread</label>
|
||||
@@ -70,6 +92,13 @@
|
||||
<button id="insert-world-notes" type="button">Insert project World Notes</button>
|
||||
<p id="carry-status" class="muted"></p>
|
||||
</div>
|
||||
<div class="lexicon-selector">
|
||||
<label>LEXICON entries <span class="read-only">select for this package</span></label>
|
||||
<div id="lexicon-entries" class="context-list"></div>
|
||||
<button id="load-lexicon" type="button">Load selected entries into LEXICON</button>
|
||||
<button id="clear-lexicon" type="button">Clear selection</button>
|
||||
<p id="lexicon-status" class="muted"></p>
|
||||
</div>
|
||||
</section>
|
||||
<div class="grid">
|
||||
<label>LEXICON (manual subset)
|
||||
@@ -91,7 +120,6 @@
|
||||
<textarea id="directive" rows="4" placeholder="Expand to a maximum of 1500 words. Bring the scene to a close…"></textarea>
|
||||
</label>
|
||||
</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>
|
||||
@@ -141,6 +169,6 @@
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/static/app.js"></script>
|
||||
<script src="/static/app.js?v=continuity-request-status-1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,11 +5,15 @@ header { display: flex; justify-content: space-between; align-items: center; pad
|
||||
h1 { margin: 0; font-size: 1.8rem; } h2 { margin: 1.5rem 0 .6rem; font-size: 1rem; } .eyebrow { margin: 0 0 .25rem; font-size: .7rem; letter-spacing: .12em; color: #c7ded6; }
|
||||
main { display: grid; grid-template-columns: 300px minmax(0, 1fr); min-height: calc(100vh - 84px); }
|
||||
aside { padding: 1.2rem 1.5rem; border-right: 1px solid #d8d2c5; background: #fcfaf5; } .workspace { padding: 1.5rem 2rem; }
|
||||
label { display: block; font-size: .85rem; font-weight: 650; } select, textarea, input[type="number"] { width: 100%; margin-top: .35rem; border: 1px solid #bab2a5; border-radius: 4px; background: #fffefb; padding: .55rem; font: .9rem ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
label { display: block; font-size: .85rem; font-weight: 650; } select, textarea, input[type="number"], input[type="text"] { 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; }
|
||||
.continuity-panel { margin-bottom: 1.25rem; padding: 0 1rem 1rem; border: 1px solid #d8d2c5; background: #eef4f0; } .continuity-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; } .continuity-panel button { margin: .6rem 0 0; } .carry-actions { margin-top: 1rem; padding-top: .25rem; border-top: 1px solid #cbd8d0; } .carry-actions button { margin-right: .5rem; } .context-list { min-height: 6rem; max-height: 12rem; overflow: auto; margin-top: .35rem; padding: .45rem; border: 1px solid #bab2a5; background: #fffefb; } .context-list label { padding: .22rem 0; font-weight: 400; }
|
||||
.lexicon-selector { margin-top: 1rem; padding-top: .25rem; border-top: 1px solid #cbd8d0; } .lexicon-selector button { margin-right: .5rem; }
|
||||
.create-scene { margin: 1.5rem -1.5rem 0; padding: .9rem 1.5rem 0; border-top: 1px solid #d8d2c5; } .create-scene h2 { margin-top: 0; } .create-scene button { width: 100%; margin: .7rem 0 .25rem; }
|
||||
.dashboard-panel { margin-bottom: 1.25rem; padding: 0 1rem 1rem; border: 1px solid #d8d2c5; background: #fcfaf5; } .dashboard-panel h2 { margin-top: 1rem; } .thread-tree { margin: .75rem 0; padding: .65rem .8rem; border-left: 3px solid #4e7b6b; background: #eef4f0; } .thread-tree > .muted { margin: 0 0 .3rem; } .thread-tree ul { list-style: none; margin: 0; padding-left: 1.35rem; } .thread-tree > ul { padding-left: 0; } .thread-tree li { position: relative; padding: .25rem 0; line-height: 1.4; } .thread-tree li li::before { content: ""; position: absolute; top: 0; bottom: 0; left: -.82rem; border-left: 1px solid #91aa9d; } .thread-tree li li::after { content: ""; position: absolute; top: .9rem; left: -.82rem; width: .55rem; border-top: 1px solid #91aa9d; } .tree-state { margin-left: .4rem; color: #49675c; font-size: .75rem; font-weight: 650; } #continuity-dashboard { max-height: 19rem; overflow: auto; } table { width: 100%; border-collapse: collapse; font-size: .82rem; } th, td { padding: .45rem .5rem; border-bottom: 1px solid #e3ddd1; text-align: left; vertical-align: top; } th { position: sticky; top: 0; background: #fcfaf5; } .scene-jump { margin: 0; padding: .15rem .35rem; background: #3d6e61; font-size: .78rem; } .scene-jump:hover { background: #2c5148; }
|
||||
.thread-controls { margin: .8rem 0 1rem; padding: .75rem; border: 1px solid #cbd8d0; background: #f8fbf8; } .thread-controls .checkbox { margin-top: 0; } .thread-controls button { margin-bottom: 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; }
|
||||
.summary-panel, .updates-panel { margin-top: 1.25rem; padding: 0 1rem 1rem; border: 1px solid #d8d2c5; background: #fcfaf5; } .proposal-list { margin: .75rem 0; } .proposal { margin: .6rem 0; padding: .6rem; border: 1px solid #d8d2c5; background: #fffefb; font-weight: 650; } .proposal textarea, .proposal input:not([type]) { margin-top: .55rem; font-weight: 400; }
|
||||
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; }
|
||||
|
||||
Reference in New Issue
Block a user