Add continuity dashboard and scene authoring workflow
This commit is contained in:
190
prototype/app.py
190
prototype/app.py
@@ -89,16 +89,25 @@ class ScrivenerReader:
|
||||
self.root_items = self._load_binder()
|
||||
self.by_uuid = {item.uuid: item for item in self.walk()}
|
||||
|
||||
def bindings(self) -> dict[str, str]:
|
||||
def binding_metadata(self) -> dict:
|
||||
if not self.bindings_path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(self.bindings_path.read_text(encoding="utf-8"))
|
||||
bindings = data.get("scene_to_brief", {})
|
||||
return {str(scene_uuid): str(brief_uuid) for scene_uuid, brief_uuid in bindings.items()} if isinstance(bindings, dict) else {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
||||
def bindings(self) -> dict[str, str]:
|
||||
bindings = self.binding_metadata().get("scene_to_brief", {})
|
||||
return {str(scene_uuid): str(brief_uuid) for scene_uuid, brief_uuid in bindings.items()} if isinstance(bindings, dict) else {}
|
||||
|
||||
def thread_predecessors(self) -> dict[str, str | None]:
|
||||
predecessors = self.binding_metadata().get("scene_predecessors", {})
|
||||
if not isinstance(predecessors, dict):
|
||||
return {}
|
||||
return {str(scene_uuid): (str(predecessor) if predecessor is not None else None) for scene_uuid, predecessor in predecessors.items()}
|
||||
|
||||
def _load_binder(self) -> list[Item]:
|
||||
root = ET.parse(self.project_file).getroot()
|
||||
binder = root.find("Binder")
|
||||
@@ -168,6 +177,39 @@ class ScrivenerReader:
|
||||
)
|
||||
return scenes
|
||||
|
||||
def manuscript_chapters(self) -> list[dict]:
|
||||
manuscript = self.top_level("Manuscript")
|
||||
if manuscript is None:
|
||||
return []
|
||||
return [{"uuid": item.uuid, "title": item.title} for item in manuscript.children if item.item_type == "Folder"]
|
||||
|
||||
@staticmethod
|
||||
def lexicon_entries(text: str) -> list[dict]:
|
||||
"""Read author-maintained ``Term — definition`` paragraphs from Master Lexicon."""
|
||||
entries: list[dict] = []
|
||||
entry_lines: list[str] = []
|
||||
|
||||
def save_entry() -> None:
|
||||
if not entry_lines:
|
||||
return
|
||||
entry = "\n".join(entry_lines).strip()
|
||||
title, _, definition = entry.partition(" — ")
|
||||
if title.strip() and definition.strip():
|
||||
entries.append({"title": title.strip(), "text": entry})
|
||||
|
||||
for raw_line in text.splitlines():
|
||||
line = raw_line.strip()
|
||||
if " — " in line:
|
||||
save_entry()
|
||||
entry_lines = [line]
|
||||
elif not line:
|
||||
save_entry()
|
||||
entry_lines = []
|
||||
elif entry_lines:
|
||||
entry_lines.append(line)
|
||||
save_entry()
|
||||
return entries
|
||||
|
||||
def summary(self) -> dict:
|
||||
authoring = self.top_level("Authoring System")
|
||||
setup = self.child(authoring, "Book Setup")
|
||||
@@ -175,6 +217,7 @@ class ScrivenerReader:
|
||||
briefs_folder = self.child(authoring, "Scene Briefs")
|
||||
summaries_folder = self.child(continuity, "Scene Summaries")
|
||||
archive = self.child(authoring, "Drafting Archive")
|
||||
accepted_folder = self.child(archive, "Accepted for Editing")
|
||||
snapshots_folder = self.child(archive, "Package Snapshots")
|
||||
style = self.child(setup, "STYLE")
|
||||
instructions = self.child(setup, "Frontier-Model Instructions")
|
||||
@@ -185,8 +228,14 @@ class ScrivenerReader:
|
||||
brief_by_title = {brief["title"]: brief for brief in briefs}
|
||||
brief_by_uuid = {brief["uuid"]: brief for brief in briefs}
|
||||
bindings = self.bindings()
|
||||
predecessors = self.thread_predecessors()
|
||||
summary_by_title = {record["title"]: record for record in self.records(summaries_folder, self)}
|
||||
character_by_title = {character["title"]: character["uuid"] for character in self.records(self.top_level("Characters"), self)}
|
||||
working_by_title: dict[str, dict] = {}
|
||||
for record in self.records(accepted_folder, self):
|
||||
match = re.match(r"^(.*?) — working copy — ", record["title"])
|
||||
if match:
|
||||
working_by_title[match.group(1)] = record
|
||||
package_context: dict[str, dict] = {}
|
||||
for snapshot in self.records(snapshots_folder, self):
|
||||
scene_title = re.search(r"^Scene:\s*(.+)$", snapshot["text"], re.MULTILINE)
|
||||
@@ -200,7 +249,24 @@ class ScrivenerReader:
|
||||
else:
|
||||
character_block = self._package_section(text, "CHARACTERS")
|
||||
character_uuids = [character_by_title[line.strip()] for line in re.findall(r"^(.+?)\n", character_block) if line.strip() in character_by_title]
|
||||
package_context[title] = {"character_uuids": character_uuids, "notes": self._package_section(text, "NOTES")}
|
||||
notes = self._package_section(text, "NOTES")
|
||||
place_uuids: list[str] = []
|
||||
for place in self.records(self.top_level("Places"), self):
|
||||
place_block = f"{place['title']} — {place['text'].strip()}"
|
||||
if place_block and place_block in notes:
|
||||
place_uuids.append(place["uuid"])
|
||||
notes = notes.replace(place_block, "").strip()
|
||||
package_context[title] = {
|
||||
"character_uuids": character_uuids,
|
||||
"place_uuids": place_uuids,
|
||||
"lexicon": self._package_section(text, "LEXICON"),
|
||||
"story": self._package_section(text, "STORY"),
|
||||
"recall": self._package_section(text, "RECALL"),
|
||||
"previous": self._package_section(text, "PREVIOUS"),
|
||||
"notes": notes,
|
||||
"directive": self._package_section(text, "DIRECTIVE"),
|
||||
"exact_package": text.split("\n\n", 1)[1] if "\n\n" in text else text,
|
||||
}
|
||||
|
||||
paired = []
|
||||
bound_brief_uuids: set[str] = set()
|
||||
@@ -219,7 +285,10 @@ class ScrivenerReader:
|
||||
"brief_uuid": (brief or {}).get("uuid"),
|
||||
"binding_state": binding_state,
|
||||
"summary": summary_by_title.get(scene["title"], {}).get("text", ""),
|
||||
"package_context": package_context.get(scene["title"], {"character_uuids": [], "notes": ""}),
|
||||
"working_copy": working_by_title.get(scene["title"]),
|
||||
"package_context": package_context.get(scene["title"], {"character_uuids": [], "place_uuids": [], "lexicon": "", "story": "", "recall": "", "previous": "", "notes": "", "directive": "", "exact_package": ""}),
|
||||
"predecessor_uuid": predecessors.get(scene["uuid"]),
|
||||
"thread_state": "new-thread" if scene["uuid"] in predecessors and predecessors[scene["uuid"]] is None else "continues" if predecessors.get(scene["uuid"]) else "unassigned",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -229,8 +298,10 @@ class ScrivenerReader:
|
||||
"style": self.content(style),
|
||||
"frontier_instructions": self.content(instructions),
|
||||
"master_lexicon": self.content(lexicon),
|
||||
"lexicon_entries": self.lexicon_entries(self.content(lexicon)),
|
||||
"world_notes": self.content(world_notes),
|
||||
"scenes": paired,
|
||||
"chapters": self.manuscript_chapters(),
|
||||
"characters": self.records(self.top_level("Characters"), self),
|
||||
"places": self.records(self.top_level("Places"), self),
|
||||
"unmatched_briefs": [brief for brief in briefs if brief["uuid"] not in bound_brief_uuids],
|
||||
@@ -259,16 +330,40 @@ class ScrivenerWriter:
|
||||
if (self.project / "Files" / "user.lock").exists():
|
||||
raise ValueError("Scrivener is open (Files/user.lock exists). Close the project before writing.")
|
||||
|
||||
def _write_bindings(self, bindings: dict[str, str]) -> None:
|
||||
payload = {
|
||||
def _write_bindings(self, bindings: dict[str, str], predecessors: dict[str, str | None] | None = None) -> None:
|
||||
payload = self.reader.binding_metadata()
|
||||
payload.update({
|
||||
"version": 1,
|
||||
"updated_at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
"scene_to_brief": bindings,
|
||||
}
|
||||
})
|
||||
if predecessors is not None:
|
||||
payload["scene_predecessors"] = predecessors
|
||||
temporary = self.reader.bindings_path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
os.replace(temporary, self.reader.bindings_path)
|
||||
|
||||
def assign_thread_predecessor(self, scene_uuid: str, predecessor_uuid: str | None) -> None:
|
||||
"""Store the author's explicit narrative-thread relationship for a scene."""
|
||||
self._assert_unlocked()
|
||||
scenes = {scene["uuid"]: scene for scene in self.reader.summary()["scenes"]}
|
||||
if scene_uuid not in scenes:
|
||||
raise ValueError("The selected Manuscript scene no longer exists.")
|
||||
if predecessor_uuid is not None:
|
||||
if predecessor_uuid not in scenes or not scenes[predecessor_uuid]["text"].strip():
|
||||
raise ValueError("A narrative predecessor must be a canonical Manuscript scene.")
|
||||
if predecessor_uuid == scene_uuid:
|
||||
raise ValueError("A scene cannot continue from itself.")
|
||||
predecessors = self.reader.thread_predecessors()
|
||||
cursor = predecessor_uuid
|
||||
while cursor is not None:
|
||||
if cursor == scene_uuid:
|
||||
raise ValueError("That narrative-thread link would create a cycle.")
|
||||
cursor = predecessors.get(cursor)
|
||||
predecessors[scene_uuid] = predecessor_uuid
|
||||
self._write_bindings(self.reader.bindings(), predecessors)
|
||||
self.reader = ScrivenerReader(self.project)
|
||||
|
||||
def reconcile_scene_bindings(self) -> dict:
|
||||
"""Persist currently resolved scene/BRIEF pairs without relying on their titles."""
|
||||
self._assert_unlocked()
|
||||
@@ -310,6 +405,18 @@ class ScrivenerWriter:
|
||||
assert parent is not None
|
||||
return parent
|
||||
|
||||
@staticmethod
|
||||
def _element_by_uuid(parent: ET.Element, item_uuid: str) -> ET.Element | None:
|
||||
for item in parent.findall("BinderItem"):
|
||||
if item.attrib.get("UUID") == item_uuid:
|
||||
return item
|
||||
children = item.find("Children")
|
||||
if children is not None:
|
||||
found = ScrivenerWriter._element_by_uuid(children, item_uuid)
|
||||
if found is not None:
|
||||
return found
|
||||
return None
|
||||
|
||||
def _backup_binder(self) -> None:
|
||||
backup_dir = self.project / "Files" / "authoring-backups"
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -390,6 +497,35 @@ class ScrivenerWriter:
|
||||
self.reader = ScrivenerReader(self.project)
|
||||
return {"created": True, "brief_uuid": brief_uuid}
|
||||
|
||||
def create_planned_scene(self, chapter_uuid: str, title: str) -> dict:
|
||||
"""Create a blank Manuscript scene and its blank, stably bound BRIEF."""
|
||||
self._assert_unlocked()
|
||||
manuscript = self.reader.top_level("Manuscript")
|
||||
chapter = self.reader.by_uuid.get(chapter_uuid)
|
||||
if manuscript is None or chapter is None or chapter_uuid not in {item.uuid for item in manuscript.children}:
|
||||
raise ValueError("Choose a current Manuscript chapter.")
|
||||
title = title.strip()
|
||||
existing_titles = {scene["title"] for scene in self.reader.summary()["scenes"]}
|
||||
if not title:
|
||||
numbers = [int(match.group(1)) for scene_title in existing_titles if (match := re.fullmatch(r"Scene\s+(\d+)", scene_title, re.IGNORECASE))]
|
||||
title = f"Scene {max(numbers, default=0) + 1}"
|
||||
if title in existing_titles:
|
||||
raise ValueError(f"A Manuscript scene named {title!r} already exists.")
|
||||
self._backup_binder()
|
||||
tree, binder = self._tree_and_binder()
|
||||
chapter_element = self._element_by_uuid(binder, chapter_uuid)
|
||||
if chapter_element is None:
|
||||
raise ValueError("The selected chapter no longer exists.")
|
||||
briefs_folder = self._folder(binder, ["Authoring System", "Scene Briefs"])
|
||||
scene_uuid = self._add_text(chapter_element, title, "")
|
||||
brief_uuid = self._add_text(briefs_folder, title, "")
|
||||
self._save_tree(tree)
|
||||
bindings = self.reader.bindings()
|
||||
bindings[scene_uuid] = brief_uuid
|
||||
self._write_bindings(bindings)
|
||||
self.reader = ScrivenerReader(self.project)
|
||||
return {"scene_uuid": scene_uuid, "brief_uuid": brief_uuid, "title": title}
|
||||
|
||||
def accept_for_editing(self, scene_uuid: str, draft_text: str, package: str, model: str, character_uuids: list[str]) -> dict:
|
||||
self._assert_unlocked()
|
||||
if not draft_text.strip() or not package.strip():
|
||||
@@ -422,7 +558,7 @@ class ScrivenerWriter:
|
||||
path.write_bytes(content)
|
||||
self._update_checksum(working_uuid, content)
|
||||
|
||||
def accept_into_book(self, scene_uuid: str, text: str) -> None:
|
||||
def accept_into_book(self, scene_uuid: str, text: str, predecessor_uuid: str | None) -> None:
|
||||
self._assert_unlocked()
|
||||
item = self.reader.by_uuid.get(scene_uuid)
|
||||
if item is None or item.item_type != "Text":
|
||||
@@ -435,6 +571,7 @@ class ScrivenerWriter:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(content)
|
||||
self._update_checksum(scene_uuid, content)
|
||||
self.assign_thread_predecessor(scene_uuid, predecessor_uuid)
|
||||
|
||||
def save_scene_summary(self, scene_uuid: str, summary: str) -> str:
|
||||
"""Create or update the one author-approved dense summary for a scene."""
|
||||
@@ -644,16 +781,19 @@ def propose_continuity_updates(project: ScrivenerReader, payload: dict) -> dict:
|
||||
raise ValueError("A canonical scene is required to propose continuity updates.")
|
||||
data = project.summary()
|
||||
context = {"characters": data["characters"], "master_lexicon": data["master_lexicon"], "world_notes": data["world_notes"]}
|
||||
system = "You propose factual continuity updates for a fiction-authoring system. Return JSON only, with exactly these keys: character_updates (array of objects with character_uuid and text), new_characters (array of objects with name and text), lexicon_additions (string), world_note_additions (string). Suggest only information newly established or materially changed by the canonical scene and absent from the supplied records. Character text must be a concise factual update, without a heading. Use a supplied UUID only when updating an existing character. Put a newly introduced named person in new_characters, never in lexicon_additions; name is the character's display name and text is an initial concise factual Character record. Do not create a new character for a passing unnamed person. Use empty arrays or empty strings when there is no justified addition."
|
||||
system = "You propose factual continuity updates for a fiction-authoring system. Return one strict JSON object and nothing else: no Markdown, explanation, or code fences. It must contain exactly these keys: character_updates (array of objects with character_uuid and text), new_characters (array of objects with name and text), lexicon_additions (string), world_note_additions (string). Suggest only information newly established or materially changed by the canonical scene and absent from the supplied records. Keep every proposed text field to at most two concise sentences and return no more than eight total proposed records across all arrays. Character text must be a concise factual update, without a heading. Use a supplied UUID only when updating an existing character. Put a newly introduced named person in new_characters, never in lexicon_additions; name is the character's display name and text is an initial concise factual Character record. Do not create a new character for a passing unnamed person. Use empty arrays or empty strings when there is no justified addition."
|
||||
user = "Existing continuity records:\n" + json.dumps(context, ensure_ascii=False) + f"\n\nCanonical scene ({scene.title}):\n{text}"
|
||||
body = json.dumps({"model": OPENROUTER_MODEL, "messages": [{"role": "system", "content": system}, {"role": "user", "content": user}], "max_tokens": 1200}).encode()
|
||||
body = json.dumps({"model": OPENROUTER_MODEL, "messages": [{"role": "system", "content": system}, {"role": "user", "content": user}], "max_tokens": 2500, "temperature": 0, "response_format": {"type": "json_object"}}).encode()
|
||||
request = urllib.request.Request("https://openrouter.ai/api/v1/chat/completions", data=body, headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=300) as response:
|
||||
result = json.loads(response.read().decode())
|
||||
except urllib.error.HTTPError as error:
|
||||
raise ValueError(f"OpenRouter returned HTTP {error.code}: {error.read().decode(errors='replace')}") from error
|
||||
raw = result.get("choices", [{}])[0].get("message", {}).get("content")
|
||||
choice = result.get("choices", [{}])[0]
|
||||
if choice.get("finish_reason") == "length":
|
||||
raise ValueError("OpenRouter stopped the continuity proposal at its output limit; nothing was saved. Please retry after restarting with the updated proposal budget.")
|
||||
raw = choice.get("message", {}).get("content")
|
||||
if not isinstance(raw, str):
|
||||
raise ValueError("OpenRouter response did not contain continuity proposals.")
|
||||
raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw.strip(), flags=re.IGNORECASE)
|
||||
@@ -664,10 +804,18 @@ def propose_continuity_updates(project: ScrivenerReader, payload: dict) -> dict:
|
||||
last_brace = raw.rfind("}")
|
||||
if first_brace >= 0 and last_brace > first_brace:
|
||||
raw = raw[first_brace:last_brace + 1]
|
||||
# Some otherwise valid model responses use Markdown-style escapes for
|
||||
# underscores or double-escape quotes inside a JSON string. Neither is
|
||||
# valid JSON; normalize only these two unambiguous formatting mistakes.
|
||||
raw = re.sub(r"\\+_", "_", raw)
|
||||
raw = re.sub(r'(?:\\){2,}(?=")', r"\\", raw)
|
||||
try:
|
||||
proposals = json.loads(raw)
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError("OpenRouter returned proposals in an unexpected format; please try again.") from error
|
||||
start = max(0, error.pos - 120)
|
||||
end = min(len(raw), error.pos + 180)
|
||||
context_excerpt = re.sub(r"\s+", " ", raw[start:end]).strip()
|
||||
raise ValueError(f"OpenRouter returned proposals in an unexpected format near character {error.pos}: {context_excerpt!r}") from error
|
||||
if not isinstance(proposals, dict):
|
||||
raise ValueError("OpenRouter returned proposals in an unexpected format; please try again.")
|
||||
valid_uuids = {character["uuid"] for character in data["characters"]}
|
||||
@@ -721,7 +869,7 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = urlparse(self.path).path
|
||||
if path not in {"/api/package-preview", "/api/draft", "/api/accept-for-editing", "/api/save-working-copy", "/api/accept-into-book", "/api/propose-summary", "/api/save-summary", "/api/propose-continuity-updates", "/api/save-continuity-updates", "/api/reconcile-bindings", "/api/save-brief"}:
|
||||
if path not in {"/api/package-preview", "/api/draft", "/api/accept-for-editing", "/api/save-working-copy", "/api/accept-into-book", "/api/propose-summary", "/api/save-summary", "/api/propose-continuity-updates", "/api/save-continuity-updates", "/api/reconcile-bindings", "/api/save-brief", "/api/save-thread-link", "/api/create-scene"}:
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
try:
|
||||
@@ -745,8 +893,8 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
if not payload.get("final_confirmation"):
|
||||
raise ValueError("Confirm final acceptance before writing canonical Manuscript text.")
|
||||
writer = ScrivenerWriter(self.reader.project)
|
||||
writer.accept_into_book(payload.get("scene_uuid", ""), payload.get("text", ""))
|
||||
AppHandler.reader = ScrivenerReader(self.reader.project)
|
||||
writer.accept_into_book(payload.get("scene_uuid", ""), payload.get("text", ""), payload.get("predecessor_uuid"))
|
||||
AppHandler.reader = writer.reader
|
||||
self.send_json({"accepted": True})
|
||||
elif path == "/api/propose-summary":
|
||||
self.send_json(propose_summary(self.reader, payload))
|
||||
@@ -776,6 +924,16 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
result = writer.save_brief(payload.get("scene_uuid", ""), payload.get("text", ""))
|
||||
AppHandler.reader = writer.reader
|
||||
self.send_json(result)
|
||||
elif path == "/api/save-thread-link":
|
||||
writer = ScrivenerWriter(self.reader.project)
|
||||
writer.assign_thread_predecessor(payload.get("scene_uuid", ""), payload.get("predecessor_uuid"))
|
||||
AppHandler.reader = writer.reader
|
||||
self.send_json({"saved": True})
|
||||
elif path == "/api/create-scene":
|
||||
writer = ScrivenerWriter(self.reader.project)
|
||||
result = writer.create_planned_scene(payload.get("chapter_uuid", ""), payload.get("title", ""))
|
||||
AppHandler.reader = writer.reader
|
||||
self.send_json(result)
|
||||
else:
|
||||
self.send_json({"package": build_package(self.reader, payload), "warnings": preflight(self.reader, payload)})
|
||||
except (ValueError, json.JSONDecodeError) as error:
|
||||
|
||||
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