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,6 +1,14 @@
# Read-only Package Preview Prototype
# Local Authoring-System Prototype
This is the first authoring-system vertical slice. It reads a Scrivener project, checks the project structure, and builds an on-screen package preview. It does **not** call a model and it does **not** write to Scrivener.
This prototype reads a Scrivener project, checks the project structure, builds an exact package preview, and can send a confirmed package to the temporary OpenRouter drafting model.
When Scrivener is closed, accepting a generated draft for editing creates three records in the project:
- an immutable initial draft in `Authoring System → Drafting Archive → Accepted for Editing`;
- an editable working copy in the same folder; and
- the exact package snapshot in `Authoring System → Drafting Archive → Package Snapshots`.
An outright rejected draft writes nothing. A separate, explicit final-accept action copies the saved working copy into its paired Manuscript scene. Every write checks `Files/user.lock`; close the project in Scrivener first. Before a structural archive write, the prototype places a recoverable binder/checksum backup in `Files/authoring-backups`.
Run from `Authoring_System`:
@@ -16,4 +24,4 @@ To point it at another project:
python3 prototype/app.py --project /absolute/path/to/Book.scriv
```
The app binds scene/brief pairs by the visible `Scene X` labels for this read-only prototype. The next phase will persist UUID bindings in a sidecar, so later renaming does not break relationships.
The app currently binds scene/brief pairs by their visible `Scene X` labels. A later phase will persist UUID bindings in a sidecar, so later renaming does not break relationships.

View File

@@ -1,18 +1,23 @@
#!/usr/bin/env python3
"""Read-only local package-preview prototype for a Scrivener project.
"""Local authoring-system prototype for a Scrivener project.
This deliberately has no model call and no Scrivener write path. It is a
small standard-library server so the first workflow test has no dependency
installation step.
It previews and sends packages, and makes carefully scoped, lock-guarded
writes for accepted drafts. It remains a standard-library server.
"""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import json
import os
import re
import shutil
import subprocess
import urllib.error
import urllib.request
import uuid
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from http import HTTPStatus
@@ -22,6 +27,7 @@ from urllib.parse import urlparse
APP_DIR = Path(__file__).resolve().parent
DEFAULT_PROJECT = APP_DIR.parent / "hapagirlsauthoringsystemv1.scriv"
OPENROUTER_MODEL = "anthropic/claude-sonnet-4.6"
@dataclass
@@ -54,6 +60,24 @@ def rtf_to_text(path: Path) -> str:
return re.sub(r"\n{3,}", "\n\n", raw).strip()
def text_to_rtf(value: str) -> str:
"""Create deliberately simple RTF suitable for Scrivener text documents."""
escaped = []
for character in value.replace("\r\n", "\n").replace("\r", "\n"):
if character == "\n":
escaped.append("\\par\n")
elif character in "\\{}":
escaped.append("\\" + character)
elif ord(character) > 127:
codepoint = ord(character)
if codepoint > 32767:
codepoint -= 65536
escaped.append(f"\\u{codepoint}?")
else:
escaped.append(character)
return "{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0\\fnil Helvetica;}}\n\\f0\\fs24 " + "".join(escaped) + "\n}"
class ScrivenerReader:
def __init__(self, project: Path):
self.project = project
@@ -175,11 +199,163 @@ class ScrivenerReader:
}
class ScrivenerWriter:
"""The small, guarded subset of Scrivener writes needed by this prototype."""
def __init__(self, project: Path):
self.project = project
self.reader = ScrivenerReader(project)
def _assert_unlocked(self) -> None:
if (self.project / "Files" / "user.lock").exists():
raise ValueError("Scrivener is open (Files/user.lock exists). Close the project before writing.")
def _tree_and_binder(self) -> tuple[ET.ElementTree, ET.Element]:
tree = ET.parse(self.reader.project_file)
binder = tree.getroot().find("Binder")
if binder is None:
raise ValueError("Scrivener project has no Binder.")
return tree, binder
@staticmethod
def _find_child(element: ET.Element, title: str) -> ET.Element | None:
children = element.find("Children")
if children is None:
return None
return next((item for item in children.findall("BinderItem") if (item.findtext("Title") or "").strip() == title), None)
def _folder(self, binder: ET.Element, path: list[str]) -> ET.Element:
parent: ET.Element | None = None
children = binder
for title in path:
parent = next((item for item in children.findall("BinderItem") if (item.findtext("Title") or "").strip() == title), None)
if parent is None:
raise ValueError("Required Scrivener folder is missing: " + "".join(path))
children = parent.find("Children")
if children is None and title != path[-1]:
raise ValueError("Required Scrivener folder has no children: " + title)
assert parent is not None
return parent
def _backup_binder(self) -> None:
backup_dir = self.project / "Files" / "authoring-backups"
backup_dir.mkdir(parents=True, exist_ok=True)
stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
shutil.copy2(self.reader.project_file, backup_dir / f"{stamp}.scrivx")
checksum = self.project / "Files" / "Data" / "docs.checksum"
if checksum.exists():
shutil.copy2(checksum, backup_dir / f"{stamp}.docs.checksum")
def _update_checksum(self, uuid: str, content: bytes) -> None:
checksum_path = self.project / "Files" / "Data" / "docs.checksum"
digest = hashlib.sha1(content).hexdigest()
key = f"{uuid.lower()}/content.rtf"
lines = checksum_path.read_text(encoding="utf-8", errors="replace").splitlines() if checksum_path.exists() else []
updated = False
output = []
for line in lines:
if line.startswith(key + "="):
output.append(f"{key}={digest}")
updated = True
else:
output.append(line)
if not updated:
output.append(f"{key}={digest}")
checksum_path.write_text("\n".join(output) + "\n", encoding="utf-8")
def _write_rtf(self, uuid: str, text: str) -> None:
content = text_to_rtf(text).encode("utf-8")
data_dir = self.project / "Files" / "Data" / uuid
data_dir.mkdir(parents=True, exist_ok=False)
(data_dir / "content.rtf").write_bytes(content)
self._update_checksum(uuid, content)
def _add_text(self, folder: ET.Element, title: str, text: str) -> str:
children = folder.find("Children")
if children is None:
children = ET.SubElement(folder, "Children")
doc_uuid = str(uuid.uuid4()).upper()
stamp = dt.datetime.now().astimezone().isoformat(timespec="seconds")
item = ET.SubElement(children, "BinderItem", {"UUID": doc_uuid, "Type": "Text", "Created": stamp, "Modified": stamp})
ET.SubElement(item, "Title").text = title
metadata = ET.SubElement(item, "MetaData")
ET.SubElement(metadata, "IncludeInCompile").text = "No"
settings = ET.SubElement(item, "TextSettings")
ET.SubElement(settings, "TextSelection").text = "0,0"
self._write_rtf(doc_uuid, text)
return doc_uuid
def _save_tree(self, tree: ET.ElementTree) -> None:
tree.write(self.reader.project_file, encoding="UTF-8", xml_declaration=True)
def accept_for_editing(self, scene_uuid: str, draft_text: str, package: str, model: str) -> dict:
self._assert_unlocked()
if not draft_text.strip() or not package.strip():
raise ValueError("A non-empty draft and package are required for acceptance.")
scene = self.reader.by_uuid.get(scene_uuid)
if scene is None:
raise ValueError("The selected scene no longer exists.")
self._backup_binder()
tree, binder = self._tree_and_binder()
accepted = self._folder(binder, ["Authoring System", "Drafting Archive", "Accepted for Editing"])
snapshots = self._folder(binder, ["Authoring System", "Drafting Archive", "Package Snapshots"])
stamp = dt.datetime.now().strftime("%Y-%m-%d %H%M")
initial_uuid = self._add_text(accepted, f"{scene.title} — initial draft — {stamp}", draft_text)
working_uuid = self._add_text(accepted, f"{scene.title} — working copy — {stamp}", draft_text)
package_header = f"Scene: {scene.title}\nModel: {model}\nAccepted for editing: {dt.datetime.now().astimezone().isoformat(timespec='seconds')}\n\n"
package_uuid = self._add_text(snapshots, f"{scene.title} — package — {stamp}", package_header + package)
self._save_tree(tree)
self.reader = ScrivenerReader(self.project)
return {"initial_uuid": initial_uuid, "working_uuid": working_uuid, "package_uuid": package_uuid}
def save_working_copy(self, working_uuid: str, text: str) -> None:
self._assert_unlocked()
item = self.reader.by_uuid.get(working_uuid)
if item is None or item.item_type != "Text":
raise ValueError("The working-copy archive record no longer exists.")
path = self.project / "Files" / "Data" / working_uuid / "content.rtf"
if not path.exists():
raise ValueError("The working-copy archive record has no text file.")
content = text_to_rtf(text).encode("utf-8")
path.write_bytes(content)
self._update_checksum(working_uuid, content)
def accept_into_book(self, scene_uuid: str, text: str) -> None:
self._assert_unlocked()
item = self.reader.by_uuid.get(scene_uuid)
if item is None or item.item_type != "Text":
raise ValueError("The selected Manuscript scene no longer exists.")
manuscript = self.reader.top_level("Manuscript")
if manuscript is None or not any(candidate.uuid == scene_uuid for candidate in self.reader.walk(manuscript.children)):
raise ValueError("The selected item is not a Manuscript scene.")
path = self.project / "Files" / "Data" / scene_uuid / "content.rtf"
content = text_to_rtf(text).encode("utf-8")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
self._update_checksum(scene_uuid, content)
def section(name: str, value: str) -> str:
return f"### {name}\n{value.strip() or '(none)'}"
def preflight(project: ScrivenerReader, payload: dict) -> list[str]:
data = project.summary()
scene = next((item for item in data["scenes"] if item["uuid"] == payload.get("scene_uuid")), None)
if scene is None or not scene["brief"].strip():
raise ValueError("Choose a scene with a paired BRIEF.")
if not data["style"].strip():
raise ValueError("STYLE is empty.")
directive = payload.get("directive", "").strip()
if not directive:
raise ValueError("DIRECTIVE is required.")
if not re.search(r"\b(maximum|max|not exceed)\b", directive, re.IGNORECASE):
raise ValueError("DIRECTIVE needs a word maximum.")
return ["No scene-specific NOTES or Place records selected; verify that no physical or time continuity constraint is needed."] if not payload.get("notes", "").strip() and not payload.get("place_uuids", []) else []
def build_package(project: ScrivenerReader, payload: dict) -> str:
preflight(project, payload)
data = project.summary()
scene = next((scene for scene in data["scenes"] if scene["uuid"] == payload.get("scene_uuid")), None)
if scene is None:
@@ -220,6 +396,27 @@ def build_package(project: ScrivenerReader, payload: dict) -> str:
) + "\n"
def draft(project: ScrivenerReader, payload: dict) -> dict:
key = os.environ.get("OPENROUTER_API_KEY")
if not key:
raise ValueError("OPENROUTER_API_KEY is not set in this server's Terminal session.")
if not payload.get("send_confirmation"):
raise ValueError("Confirm the OpenRouter send before generating.")
system = project.summary()["frontier_instructions"].strip()
package = build_package(project, payload)
body = json.dumps({"model": OPENROUTER_MODEL, "messages": [{"role": "system", "content": system}, {"role": "user", "content": package}], "max_tokens": int(payload.get("max_tokens", 3000))}).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
text = result.get("choices", [{}])[0].get("message", {}).get("content")
if not isinstance(text, str):
raise ValueError("OpenRouter response did not contain draft text.")
return {"draft": text, "usage": result.get("usage", {}), "model": result.get("model", OPENROUTER_MODEL)}
class AppHandler(SimpleHTTPRequestHandler):
reader: ScrivenerReader
@@ -240,7 +437,7 @@ class AppHandler(SimpleHTTPRequestHandler):
self.send_json(self.reader.summary())
return
if path == "/api/health":
self.send_json({"status": "ok", "mode": "read-only prototype"})
self.send_json({"status": "ok", "mode": "local prototype"})
return
if path == "/":
self.path = "/static/index.html"
@@ -252,13 +449,36 @@ class AppHandler(SimpleHTTPRequestHandler):
super().do_GET()
def do_POST(self) -> None:
if urlparse(self.path).path != "/api/package-preview":
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"}:
self.send_error(HTTPStatus.NOT_FOUND)
return
try:
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length).decode("utf-8"))
self.send_json({"package": build_package(self.reader, payload)})
if path == "/api/draft":
self.send_json(draft(self.reader, payload))
elif path == "/api/accept-for-editing":
if not payload.get("accept_confirmation"):
raise ValueError("Confirm acceptance before creating archive records.")
package = build_package(self.reader, payload)
writer = ScrivenerWriter(self.reader.project)
result = writer.accept_for_editing(payload.get("scene_uuid", ""), payload.get("draft", ""), package, payload.get("model", OPENROUTER_MODEL))
AppHandler.reader = writer.reader
self.send_json(result)
elif path == "/api/save-working-copy":
writer = ScrivenerWriter(self.reader.project)
writer.save_working_copy(payload.get("working_uuid", ""), payload.get("text", ""))
self.send_json({"saved": True})
elif path == "/api/accept-into-book":
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)
self.send_json({"accepted": True})
else:
self.send_json({"package": build_package(self.reader, payload), "warnings": preflight(self.reader, payload)})
except (ValueError, json.JSONDecodeError) as error:
self.send_json({"error": str(error)}, HTTPStatus.BAD_REQUEST)
@@ -267,7 +487,7 @@ class AppHandler(SimpleHTTPRequestHandler):
def main() -> None:
parser = argparse.ArgumentParser(description="Run the read-only authoring-system prototype.")
parser = argparse.ArgumentParser(description="Run the local authoring-system prototype.")
parser.add_argument("--project", type=Path, default=DEFAULT_PROJECT)
parser.add_argument("--port", type=int, default=8765)
args = parser.parse_args()
@@ -276,7 +496,7 @@ def main() -> None:
parser.error(f"Project not found: {project}")
AppHandler.reader = ScrivenerReader(project)
server = ThreadingHTTPServer(("127.0.0.1", args.port), AppHandler)
print(f"Read-only authoring prototype: http://127.0.0.1:{args.port}")
print(f"Local authoring prototype: http://127.0.0.1:{args.port}")
print(f"Project: {project}")
server.serve_forever()

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; } }