feat: enhance LaTeX editing and compilation features

- Added summary field to history entries for better tracking of edits.
- Updated prompts to return both LaTeX and a summary in specific tags.
- Implemented compile cooldown mechanism to prevent rapid recompilation.
- Improved compile state management with JSON file storage.
- Enhanced error handling and user feedback for compilation status.
- Updated CSS for a neobrutalist design with improved button styles and dropzone support.
- Refactored HTML templates to support new features and improve user experience.
main
Antonio De Lucreziis 6 months ago
parent 763ec9e384
commit fcc9b086db

@ -1,4 +1,5 @@
import base64
import hashlib
import json
import logging
import mimetypes
@ -78,6 +79,7 @@ class HistoryEntry(TypedDict, total=False):
instructions: str
model: str
status: str
summary: str
RUNS_DIR.mkdir(parents=True, exist_ok=True)
@ -106,6 +108,7 @@ def _append_history_entry(run_dir: Path, entry: HistoryEntry) -> None:
"instructions": entry.get("instructions", ""),
"model": entry.get("model", ""),
"status": entry.get("status", ""),
"summary": entry.get("summary", ""),
}
path = _history_path(run_dir)
with path.open("a", encoding="utf-8") as f:
@ -140,6 +143,7 @@ def _load_history_for_ip(run_dir: Path, ip: str) -> list[HistoryEntry]:
"instructions": obj.get("instructions", ""),
"model": obj.get("model", ""),
"status": obj.get("status", ""),
"summary": obj.get("summary", ""),
}
)
except Exception:
@ -271,10 +275,11 @@ CONVERT_PROMPT = (
EDIT_SYSTEM_PROMPT = (
"You are an expert LaTeX/TikZ editor. "
"Given an existing standalone LaTeX/TikZ document and an edit request, return EXACTLY ONE updated complete LaTeX document. "
"Return ONLY raw LaTeX source: no markdown, no code fences, no commentary, no extra text. "
"The output must compile with pdflatex. "
"Wrap LaTeX to roughly 80 characters per line, keep formatting tidy, and add brief LaTeX comments to clarify sections when helpful."
"Given an existing standalone LaTeX/TikZ document and an edit request, return EXACTLY TWO elements: "
"a complete LaTeX document wrapped in <latex>...</latex> and a very generic summary (less than 7 words, all lowercase) wrapped in <summary>...</summary>. "
"Return ONLY these tags and their contents: no markdown, no code fences, no commentary, no extra text. "
"The LaTeX must compile with pdflatex. "
"Wrap LaTeX to roughly 80 characters per line, keep formatting tidy, and add brief LaTeX comments where helpful."
)
EDIT_PROMPT_TEMPLATE = (
@ -282,7 +287,7 @@ EDIT_PROMPT_TEMPLATE = (
"Rules:\n"
"- Return a complete standalone LaTeX document (\\documentclass{{standalone}} ... \\end{{document}}).\n"
"- Make minimal changes necessary to satisfy the instructions.\n"
"- Do not add external files or assets.\n\n"
"- Do not add external files or assets.\n"
"INSTRUCTIONS:\n{instructions}\n\n"
"LATEX:\n{latex}\n"
)
@ -326,6 +331,22 @@ def _extract_text_from_litellm(resp: Any) -> str:
return str(resp).strip()
def _parse_latex_and_summary(raw_text: str) -> tuple[str, str]:
"""Extracts <latex>...</latex> and <summary>...</summary> from raw_text.
Falls back to treating the whole raw_text as LaTeX if tags are missing.
"""
latex_re = re.compile(r"<latex>(.*?)</latex>", re.DOTALL | re.IGNORECASE)
summary_re = re.compile(r"<summary>(.*?)</summary>", re.DOTALL | re.IGNORECASE)
latex_match = latex_re.search(raw_text or "")
summary_match = summary_re.search(raw_text or "")
latex = (latex_match.group(1) if latex_match else (raw_text or "")).strip()
summary = (summary_match.group(1) if summary_match else "").strip()
return latex, summary
def _litellm_call(*, model: str, messages: list[dict[str, Any]], **kwargs: Any) -> Any:
return completion(model=model, messages=messages, **kwargs)
@ -439,6 +460,46 @@ def _render_png_with_magick(
return None
COMPILE_COOLDOWN_SECS = 3
COMPILE_STATE_FILE = "compile_state.json"
def _load_compile_state(run_dir: Path) -> dict[str, Any]:
path = run_dir / COMPILE_STATE_FILE
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8", errors="replace"))
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _save_compile_state(run_dir: Path, state: dict[str, Any]) -> None:
path = run_dir / COMPILE_STATE_FILE
path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def _tex_digest(text: str) -> str:
return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()
def _should_compile(
run_dir: Path, tex_text: str, *, cooldown_s: int = COMPILE_COOLDOWN_SECS
) -> tuple[bool, str, str]:
state = _load_compile_state(run_dir)
last_ts = float(state.get("last_ts", 0.0) or 0.0)
last_hash = state.get("last_hash") if isinstance(state.get("last_hash"), str) else ""
now = time.time()
digest = _tex_digest(tex_text)
if now - last_ts < cooldown_s:
return False, "cooldown", digest
if last_hash and last_hash == digest:
return False, "unchanged", digest
return True, "ok", digest
def _render_svg_with_pdf2svg(
run_dir: Path, pdf_path: Path, run_logger: RunLogger, section_title: str, svg_name: str = "diagram.svg"
) -> Optional[Path]:
@ -468,7 +529,27 @@ def _compile_latex_to_png(
def _compile_phase(
run_dir: Path, tex_path: Path, run_log: RunLogger, phase: str, start_msg: str = "compile.start"
) -> tuple[Optional[Path], Optional[Path], Optional[Path], int]:
) -> tuple[Optional[Path], Optional[Path], Optional[Path], int, str]:
try:
tex_text = tex_path.read_text(encoding="utf-8", errors="replace")
except Exception:
tex_text = ""
allowed, reason, digest = _should_compile(run_dir, tex_text, cooldown_s=COMPILE_COOLDOWN_SECS)
pdf_path_existing = run_dir / Path(tex_path.name).with_suffix(".pdf").name
png_path_existing = run_dir / "preview.png"
svg_path_existing = run_dir / "diagram.svg"
if not allowed:
pdf_path = pdf_path_existing if pdf_path_existing.exists() else None
png_path = png_path_existing if png_path_existing.exists() else None
svg_path = svg_path_existing if svg_path_existing.exists() else None
run_log.line(
f"{start_msg}.skip reason={reason} has_pdf={pdf_path is not None} has_png={png_path is not None} has_svg={svg_path is not None}"
)
return pdf_path, png_path, svg_path, 0, reason
run_log.line(start_msg)
started = time.monotonic()
pdf_path, png_path = _compile_latex_to_png(run_dir, tex_path, run_log, phase=phase)
@ -478,17 +559,38 @@ def _compile_phase(
else None
)
elapsed_ms = int((time.monotonic() - started) * 1000)
_save_compile_state(
run_dir,
{
"last_ts": time.time(),
"last_hash": digest,
},
)
run_log.line(
f"compile.end elapsed_ms={elapsed_ms} ok_pdf={pdf_path is not None} ok_png={png_path is not None} ok_svg={svg_path is not None}"
)
return pdf_path, png_path, svg_path, elapsed_ms
return pdf_path, png_path, svg_path, elapsed_ms, "compiled"
BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
RUN_ID_RE = re.compile(r"(\d{4}-\d{2}-\d{2}-[0-9a-f]{32}|[0-9A-Za-z]{22})")
RUN_ID_RE = re.compile(r"\d{4}-\d{2}-\d{2}-[0-9a-f]{32}")
def _uuid_to_base62(u: uuid.UUID) -> str:
n: int = int.from_bytes(u.bytes, "big", signed=False)
base = len(BASE62_ALPHABET)
chars: list[str] = []
while n > 0:
n, rem = divmod(n, base)
chars.append(BASE62_ALPHABET[rem])
encoded = "".join(reversed(chars or [BASE62_ALPHABET[0]]))
return encoded.rjust(22, BASE62_ALPHABET[0])
def _new_run_id() -> str:
return f"{time.strftime('%Y-%m-%d')}-{uuid.uuid4().hex}"
return _uuid_to_base62(uuid.uuid4())
def _write_run_meta(run_dir: Path, meta: dict[str, Any]) -> None:
@ -674,8 +776,11 @@ async def convert(request: Request, image: UploadFile = File(...), notes: str =
png_path: Optional[Path] = None
svg_path: Optional[Path] = None
cp_elapsed_ms = 0
cp_reason = ""
if tex:
pdf_path, png_path, svg_path, cp_elapsed_ms = _compile_phase(
pdf_path, png_path, svg_path, cp_elapsed_ms, cp_reason = _compile_phase(
run_dir, tex_path, run_log, phase="convert"
)
logger.info(
@ -724,11 +829,12 @@ async def convert(request: Request, image: UploadFile = File(...), notes: str =
f"convert.end elapsed_ms={elapsed_ms} ok_pdf={pdf_path is not None} ok_png={png_path is not None}",
)
return RedirectResponse(url=f"{BASE_PATH}/{run_id}", status_code=303)
warn_qs = f"?warn={cp_reason}" if cp_elapsed_ms == 0 and cp_reason else ""
return RedirectResponse(url=f"{BASE_PATH}/d/{run_id}{warn_qs}", status_code=303)
@app.get("/{run_id}", response_class=HTMLResponse)
async def get_run_result(request: Request, run_id: str):
@app.get("/d/{run_id}", response_class=HTMLResponse)
async def get_run_result(request: Request, run_id: str, warn: str | None = None):
run_dir = _run_dir_for_id(run_id)
if run_dir is None:
return HTMLResponse("Not found", status_code=404)
@ -765,6 +871,15 @@ async def get_run_result(request: Request, run_id: str):
download_pdf_url = file_url(pdf_path.name) if pdf_path.exists() else file_url("diagram.pdf")
download_svg_url = file_url(svg_path.name) if svg_path.exists() else file_url("diagram.svg")
def _warn_text(reason: str | None) -> str:
if not reason:
return ""
if reason == "cooldown":
return "Please wait a few seconds before compiling again."
if reason == "unchanged":
return "Source unchanged; compile skipped."
return "Compilation skipped."
return templates.TemplateResponse(
"edit.html",
{
@ -783,11 +898,12 @@ async def get_run_result(request: Request, run_id: str):
"base_path": BASE_PATH,
"edit_remaining": rate_status["edit_remaining"],
"edit_limit": rate_status["edit_limit"],
"compile_warning": _warn_text(warn),
},
)
@app.post("/{run_id}", response_class=HTMLResponse)
@app.post("/d/{run_id}", response_class=HTMLResponse)
async def edit_tex(request: Request, run_id: str, instructions: str = Form(...), latex: str = Form(...)):
run_dir = _run_dir_for_id(run_id)
if run_dir is None:
@ -800,7 +916,7 @@ async def edit_tex(request: Request, run_id: str, instructions: str = Form(...),
instructions = (instructions or "").strip()
if not instructions:
_write_run_error(run_dir, "Edit instructions are empty.")
return RedirectResponse(url=f"{BASE_PATH}/{run_id}", status_code=303)
return RedirectResponse(url=f"{BASE_PATH}/d/{run_id}", status_code=303)
tex_path = run_dir / "diagram.tex"
current_tex = latex
@ -825,6 +941,7 @@ async def edit_tex(request: Request, run_id: str, instructions: str = Form(...),
raw_text = ""
new_tex = ""
used_model = ""
summary_text = ""
try:
llm_started = time.monotonic()
run_log.line("llm.start")
@ -836,7 +953,7 @@ async def edit_tex(request: Request, run_id: str, instructions: str = Form(...),
],
run_log=run_log,
)
new_tex = (raw_text or "").strip()
new_tex, summary_text = _parse_latex_and_summary(raw_text or "")
llm_elapsed_ms = int((time.monotonic() - llm_started) * 1000)
logger.info(
"edit.llm.ok run_id=%s elapsed_ms=%s raw_chars=%s tex_chars=%s",
@ -861,8 +978,13 @@ async def edit_tex(request: Request, run_id: str, instructions: str = Form(...),
png_path: Optional[Path] = None
svg_path: Optional[Path] = None
cp_elapsed_ms = 0
cp_reason = ""
if new_tex:
pdf_path, png_path, svg_path, cp_elapsed_ms = _compile_phase(run_dir, tex_path, run_log, phase="edit")
pdf_path, png_path, svg_path, cp_elapsed_ms, cp_reason = _compile_phase(
run_dir, tex_path, run_log, phase="edit"
)
logger.info(
"edit.compile.done run_id=%s elapsed_ms=%s ok_pdf=%s ok_png=%s ok_svg=%s",
run_id,
@ -872,19 +994,21 @@ async def edit_tex(request: Request, run_id: str, instructions: str = Form(...),
svg_path is not None,
)
history_status = "ok" if pdf_path else "error"
_append_history_entry(
run_dir,
{
"ts": time.time(),
"ip": client_ip,
"action": "edit",
"latex": new_tex,
"instructions": instructions,
"model": used_model or (EDIT_MODELS[0] if EDIT_MODELS else ""),
"status": history_status,
},
)
if cp_elapsed_ms > 0:
history_status = "ok" if pdf_path else "error"
_append_history_entry(
run_dir,
{
"ts": time.time(),
"ip": client_ip,
"action": "edit",
"latex": new_tex,
"instructions": instructions,
"model": used_model or (EDIT_MODELS[0] if EDIT_MODELS else ""),
"status": history_status,
"summary": summary_text,
},
)
run_log.section("edit.llm_raw", raw_text or "")
@ -941,10 +1065,11 @@ async def edit_tex(request: Request, run_id: str, instructions: str = Form(...),
f"edit.end elapsed_ms={elapsed_ms} ok_pdf={pdf_path is not None} ok_png={png_path is not None}",
)
return RedirectResponse(url=f"{BASE_PATH}/{run_id}", status_code=303)
warn_qs = f"?warn={cp_reason}" if cp_elapsed_ms == 0 and cp_reason else ""
return RedirectResponse(url=f"{BASE_PATH}/d/{run_id}{warn_qs}", status_code=303)
@app.post("/{run_id}/compile", response_class=HTMLResponse)
@app.post("/d/{run_id}/compile", response_class=HTMLResponse)
async def compile_tex(request: Request, run_id: str, latex: str = Form(...)):
run_dir = _run_dir_for_id(run_id)
if run_dir is None:
@ -964,8 +1089,11 @@ async def compile_tex(request: Request, run_id: str, latex: str = Form(...)):
png_path: Optional[Path] = None
svg_path: Optional[Path] = None
cp_elapsed_ms = 0
cp_reason = ""
if tex.strip():
pdf_path, png_path, svg_path, cp_elapsed_ms = _compile_phase(
pdf_path, png_path, svg_path, cp_elapsed_ms, cp_reason = _compile_phase(
run_dir, tex_path, run_log, phase="compile", start_msg="compile.invoke"
)
logger.info(
@ -977,19 +1105,20 @@ async def compile_tex(request: Request, run_id: str, latex: str = Form(...)):
svg_path is not None,
)
history_status = "ok" if pdf_path else "error"
_append_history_entry(
run_dir,
{
"ts": time.time(),
"ip": client_ip,
"action": "compile",
"latex": tex,
"instructions": "",
"model": "",
"status": history_status,
},
)
if cp_elapsed_ms > 0:
history_status = "ok" if pdf_path else "error"
_append_history_entry(
run_dir,
{
"ts": time.time(),
"ip": client_ip,
"action": "compile",
"latex": tex,
"instructions": "",
"model": "",
"status": history_status,
},
)
meta = _read_run_meta(run_dir)
model_label = _get_meta_string(meta, "model")
@ -1028,10 +1157,11 @@ async def compile_tex(request: Request, run_id: str, latex: str = Form(...)):
logger.info("compile.end run_id=%s elapsed_ms=%s", run_id, elapsed_ms)
run_log.line(f"compile.finish elapsed_ms={elapsed_ms}")
return RedirectResponse(url=f"{BASE_PATH}/{run_id}", status_code=303)
warn_qs = f"?warn={cp_reason}" if cp_elapsed_ms == 0 and cp_reason else ""
return RedirectResponse(url=f"{BASE_PATH}/d/{run_id}{warn_qs}", status_code=303)
@app.get("/{run_id}/history")
@app.get("/d/{run_id}/history")
async def get_history(request: Request, run_id: str):
run_dir = _run_dir_for_id(run_id)
if run_dir is None:
@ -1051,6 +1181,7 @@ async def get_history(request: Request, run_id: str):
"instructions": entry.get("instructions", ""),
"model": entry.get("model", ""),
"status": entry.get("status", ""),
"summary": entry.get("summary", ""),
}
)

@ -6,155 +6,152 @@
*::after {
box-sizing: border-box;
}
html,
html {
height: 100%;
color-scheme: light dark;
}
body {
height: 100%;
}
body {
margin: 0;
}
img {
max-width: 100%;
height: auto;
display: block;
}
textarea {
textarea,
input,
select,
button {
font: inherit;
}
}
@layer base {
:root {
color-scheme: light dark;
--border: 1px solid rgba(127, 127, 127, 0.35);
--border-danger: 1px solid rgba(255, 0, 0, 0.35);
--radius-lg: 0.75rem;
--radius-md: 0.625rem;
--pad-1: 0.5rem;
--pad-2: 0.75rem;
--pad-3: 1rem;
--pad-4: 1.5rem;
--text-dim: 0.78;
--btn-bg: #f2f3f5;
--btn-bg-hover: #e7e8ec;
--btn-bg-active: #d9dbe0;
--btn-border: #d0d4db;
--btn-text: #20242c;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--sans: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif;
--bg: #e0e0e0;
--fg: #121212;
--border-color: #000000;
/* Neobrutalist Palette */
--c-primary: #a390e4; /* Purple */
--c-secondary: #90e4c1; /* Mint */
--c-accent: #ffc900; /* Yellow */
--c-danger: #ff6b6b; /* Red */
--c-surface: #ffffff;
--border-width: 2px;
--border: var(--border-width) solid var(--border-color);
--shadow: 4px 4px 0 0 var(--border-color);
--shadow-sm: 2px 2px 0 0 var(--border-color);
--shadow-hover: 6px 6px 0 0 var(--border-color);
--pad-xs: 0.25rem;
--pad-sm: 0.5rem;
--pad-md: 0.75rem;
--pad-lg: 1.5rem;
--font-sans: "Inter", system-ui, -apple-system, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace;
--checkerboard-color: light-dark(rgb(from #000 r g b / 0.2), rgb(from #fff r g b / 0.2));
}
@media (prefers-color-scheme: dark) {
:root {
--btn-bg: #2d3138;
--btn-bg-hover: #343944;
--btn-bg-active: #2a2e35;
--btn-border: #3c414c;
--btn-text: #e8ecf2;
/* Gruvbox Dark Palette */
--bg: #1d2021;
--fg: #ebdbb2;
--border-color: #504945;
--c-primary: #d3869b; /* Gruvbox Purple */
--c-secondary: #8ec07c; /* Gruvbox Green */
--c-accent: #fabd2f; /* Gruvbox Yellow */
--c-danger: #fb4934; /* Gruvbox Red */
--c-surface: #282828;
}
}
body {
font-family: var(--sans);
min-height: 100vh;
font-family: var(--font-sans);
background-color: var(--bg);
color: var(--fg);
line-height: 1.4;
/* Dot pattern background */
background-image: radial-gradient(rgb(from var(--border-color) r g b / 0.5) 1px, transparent 1px);
background-size: 20px 20px;
}
code {
font-family: var(--mono);
font-size: 0.95em;
code,
pre,
textarea,
.mono {
font-family: var(--font-mono);
}
h1,
h2,
h3 {
margin: 0;
font-weight: 900;
text-transform: uppercase;
letter-spacing: -0.02em;
}
}
@layer components {
.wrap {
max-width: 54rem;
margin-inline: auto;
padding: var(--pad-4);
}
.label {
font-size: 0.75rem;
opacity: var(--text-dim);
font-weight: 600;
}
.hint {
font-size: 0.75rem;
opacity: var(--text-dim);
max-width: 50rem;
margin: 0 auto;
padding: var(--pad-lg);
}
.card {
background: var(--c-surface);
border: var(--border);
border-radius: var(--radius-lg);
padding: var(--pad-3);
}
.field {
box-shadow: var(--shadow);
padding: var(--pad-md);
display: grid;
grid-template-rows: auto 1fr;
gap: 0.4rem;
label {
font-size: 0.875rem;
opacity: 0.9;
}
input[type="file"],
input[type="text"] {
width: 100%;
}
input[type="text"] {
padding: var(--pad-2);
border-radius: var(--radius-lg);
border: var(--border);
background: transparent;
color: inherit;
}
textarea {
width: 100%;
min-height: 6rem;
padding: var(--pad-2);
border-radius: var(--radius-lg);
border: var(--border);
background: transparent;
color: inherit;
resize: vertical;
}
gap: var(--pad-md);
}
.btn,
button,
a.button,
.btn {
a.button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.625rem 0.875rem;
border-radius: var(--radius-md);
border: 1px solid var(--btn-border);
background: var(--btn-bg);
cursor: pointer;
padding: var(--pad-sm) var(--pad-md);
background: var(--c-primary);
color: oklch(from var(--c-primary) calc(1 - l) c h);
border: var(--border);
box-shadow: var(--shadow-sm);
font-weight: 800;
text-decoration: none;
color: var(--btn-text);
gap: 0.4rem;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5), inset 0 -1px 0 rgba(0, 0, 0, 0.125);
cursor: pointer;
transition: all 0.1s;
gap: 0.5rem;
font-size: 0.85rem;
text-transform: uppercase;
&:hover {
background: var(--btn-bg-hover);
transform: translate(-2px, -2px);
box-shadow: var(--shadow);
filter: brightness(1.1);
}
&:active {
background: var(--btn-bg-active);
transform: translate(2px, 2px);
box-shadow: 0 0 0 0 var(--border-color);
}
&[disabled] {
opacity: 0.5;
cursor: not-allowed;
box-shadow: none;
transform: none;
filter: grayscale(1);
}
.icon {
@ -163,54 +160,164 @@
}
}
/* Variant buttons */
.btn-accent {
background: var(--c-accent);
}
.btn-secondary {
background: var(--c-secondary);
}
.btn-danger {
background: var(--c-danger);
color: oklch(from var(--c-danger) calc(1 - l) c h);
}
input[type="text"],
input[type="file"],
textarea,
select {
width: 100%;
padding: var(--pad-sm);
border: var(--border);
background: var(--c-surface);
border-radius: 0;
outline: none;
font-size: 0.9rem;
transition: box-shadow 0.1s;
&:focus {
box-shadow: var(--shadow-sm);
background: var(--c-surface);
}
}
textarea {
resize: vertical;
min-height: 4rem;
}
.label {
font-weight: 800;
text-transform: uppercase;
font-size: 0.7rem;
margin-bottom: 0.25rem;
display: block;
background: var(--c-accent);
color: oklch(from var(--c-accent) calc(1 - l) c h);
display: inline-block;
padding: 0.1rem 0.3rem;
border: var(--border);
box-shadow: 2px 2px 0 0 var(--border-color);
}
.hint {
font-size: 0.75rem;
font-weight: 600;
opacity: 0.7;
margin-top: 0.25rem;
}
.error {
border: var(--border-danger);
border-radius: var(--radius-lg);
padding: var(--pad-3);
background: var(--c-danger);
color: var(--fg);
border: var(--border);
padding: var(--pad-sm);
font-weight: bold;
box-shadow: var(--shadow);
white-space: pre-wrap;
}
.field {
display: grid;
gap: 0.25rem;
label {
font-size: 0.875rem;
font-weight: bold;
}
}
/* Visually hidden but accessible (for native input) */
.sr-only {
position: absolute;
width: 1px !important;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Dropzone styling (applies to label or div) */
.dropzone {
cursor: pointer;
position: relative;
padding: 2rem;
text-align: center;
border: 2px dashed var(--border-color);
background: var(--bg);
transition: all 0.2s;
&:hover {
background: oklch(from var(--bg) calc(l - 0.05) c h);
}
&.drag-over {
background: var(--c-accent);
transform: scale(1.01);
}
}
}
@layer pages {
/* Index page */
.page-index {
display: grid;
place-items: center;
min-height: 100vh;
.wrap {
display: grid;
gap: 1rem;
width: 100%;
}
h1 {
font-size: 1.25rem;
margin: 0;
background: var(--c-secondary);
border: var(--border);
box-shadow: var(--shadow);
padding: var(--pad-sm);
text-align: center;
margin-bottom: var(--pad-md);
font-size: 1.5rem;
}
p {
margin: 0;
opacity: 0.88;
line-height: 1.45;
}
form {
display: grid;
gap: 0.9rem;
background: var(--c-surface);
border: var(--border);
padding: var(--pad-sm);
margin-bottom: var(--pad-md);
font-size: 0.9rem;
font-weight: 500;
}
.preview {
display: none;
margin-top: 0.75rem;
padding: var(--pad-2);
border-radius: var(--radius-lg);
margin-top: var(--pad-sm);
padding: var(--pad-sm);
border: var(--border);
background: var(--bg);
img {
border-radius: var(--radius-md);
border: var(--border);
}
.meta {
margin-top: 0.5rem;
margin-top: var(--pad-xs);
font-family: var(--font-mono);
font-size: 0.75rem;
opacity: var(--text-dim);
word-break: break-word;
border-top: var(--border);
padding-top: var(--pad-xs);
}
}
}
@ -219,146 +326,172 @@
.page-result {
display: grid;
grid-template-rows: auto 1fr;
min-height: 100vh;
.topbar {
display: flex;
gap: 0.75rem;
gap: var(--pad-md);
align-items: center;
padding: var(--pad-2) var(--pad-3);
padding: var(--pad-sm) var(--pad-md);
border-bottom: var(--border);
background: var(--c-surface);
z-index: 10;
a {
text-decoration: none;
font-weight: 800;
text-transform: uppercase;
color: var(--fg);
&:hover {
text-decoration: underline;
}
}
.label {
margin: 0;
box-shadow: none;
background: var(--c-secondary);
color: oklch(from var(--c-secondary) calc(1 - l) c h);
}
}
.container {
min-height: 0;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: 40% 60%;
height: 100%;
}
.pane {
min-width: 0;
padding: var(--pad-3);
}
padding: var(--pad-md);
overflow-y: auto;
display: flex;
flex-direction: column;
align-items: start;
gap: var(--pad-md);
height: 100%;
.left {
border-right: var(--border);
display: grid;
grid-template-rows: auto 1fr;
gap: 0.75rem;
&.left {
border-right: var(--border);
background: var(--c-surface);
}
&.right {
background: var(--bg);
}
}
.edit-form {
display: flex;
flex-direction: column;
gap: var(--pad-md);
flex: 1;
width: 100%;
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: auto 1fr;
gap: 0.75rem;
.prompt-row {
.field {
display: flex;
gap: 0.5rem;
align-items: stretch;
flex-direction: column;
gap: 0.25rem;
input[type="text"] {
&:has(textarea) {
flex: 1;
min-width: 0;
}
.btn {
white-space: nowrap;
min-height: 0;
}
}
.actions {
.prompt-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
gap: var(--pad-sm);
input {
flex: 1;
}
button {
white-space: nowrap;
}
}
textarea {
min-height: 10rem;
flex: 1;
min-height: 0;
font-family: var(--font-mono);
font-size: 0.8rem;
line-height: 1.5;
white-space: pre;
border: var(--border);
box-shadow: inset 2px 2px 0 0 rgba(0, 0, 0, 0.05);
}
}
/* Fallback: ensure layout even without CSS nesting support */
.edit-form .prompt-row {
display: flex;
gap: 0.5rem;
align-items: stretch;
}
.edit-form .prompt-row input[type="text"] {
flex: 1;
min-width: 0;
}
.edit-form .prompt-row .btn {
white-space: nowrap;
}
.right {
display: grid;
grid-template-rows: auto 1fr auto auto;
gap: 0.75rem;
}
textarea {
width: 100%;
height: 100%;
resize: none;
font-family: var(--mono);
font-size: 0.85rem;
line-height: 1.45;
padding: var(--pad-2);
border-radius: var(--radius-lg);
border: var(--border);
white-space: pre;
overflow: auto;
.actions {
display: flex;
gap: var(--pad-sm);
}
}
.preview {
min-height: 15rem;
display: grid;
place-items: center;
border-radius: var(--radius-lg);
border: var(--border);
background: var(--c-surface);
background-image: linear-gradient(
45deg,
var(--checkerboard-color) 25%,
transparent 25%,
transparent 75%,
var(--checkerboard-color) 75%,
var(--checkerboard-color)
),
linear-gradient(
45deg,
var(--checkerboard-color) 25%,
transparent 25%,
transparent 75%,
var(--checkerboard-color) 75%,
var(--checkerboard-color)
);
background-size: 12px 12px;
background-position: 0 0, 6px 6px;
padding: var(--pad-md);
overflow: auto;
box-shadow: var(--shadow);
img {
max-width: 100%;
}
}
.downloads {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
gap: var(--pad-sm);
.error {
font-family: var(--mono);
font-size: 0.85rem;
.button {
font-size: 0.75rem;
padding: var(--pad-xs) var(--pad-sm);
background: var(--c-surface);
color: oklch(from var(--c-surface) calc(1 - l) c h);
&:hover {
background: var(--c-secondary);
color: oklch(from var(--c-secondary) calc(1 - l) c h);
}
}
}
@media (max-width: 60rem) {
.container {
grid-template-columns: 1fr;
grid-template-rows: 1fr auto;
}
.left {
.pane.left {
border-right: none;
border-bottom: var(--border);
}
}
@media (max-width: 40rem) {
.topbar {
flex-wrap: wrap;
gap: 0.5rem;
padding: var(--pad-2) var(--pad-2);
}
.topbar .label {
display: none;
.label {
display: none;
}
}
}
}

@ -17,7 +17,7 @@
<div class="container">
<div class="pane left">
<div class="label">Standalone LaTeX/TikZ</div>
<form action="{{ base_path }}/{{ run_id }}" method="post" class="edit-form">
<form action="{{ base_path }}/d/{{ run_id }}" method="post" class="edit-form">
<div class="field">
<label for="instructions">Edit instructions</label>
<div class="prompt-row">
@ -50,10 +50,13 @@
<textarea id="latex-source" name="latex" spellcheck="false" wrap="off">{{ tex }}</textarea>
</div>
<div class="actions">
<button class="btn" type="submit" formaction="{{ base_path }}/{{ run_id }}/compile">
<button class="btn" type="submit" formaction="{{ base_path }}/d/{{ run_id }}/compile">
<i class="fa-solid fa-play icon" aria-hidden="true"></i>
<span>Compile</span>
</button>
{% if compile_warning %}
<div class="hint warning" id="compile-warning">{{ compile_warning }}</div>
{% endif %}
</div>
</form>
</div>
@ -69,11 +72,26 @@
</div>
<div class="downloads">
<a class="button" href="{{ download_original_url }}">Download original image</a>
<a class="button" href="{{ download_tex_url }}">Download .tex</a>
<a class="button" href="{{ download_pdf_url }}">Download .pdf</a>
<a class="button" href="{{ download_svg_url }}">Download .svg</a>
<a class="button" href="{{ download_png_url }}">Download .png</a>
<a class="button" href="{{ download_original_url }}">
<i class="fa-solid fa-image icon" aria-hidden="true"></i>
<span>Original Image</span>
</a>
<a class="button" href="{{ download_tex_url }}">
<i class="fa-solid fa-file-code icon" aria-hidden="true"></i>
<span>LaTeX (.tex)</span>
</a>
<a class="button" href="{{ download_pdf_url }}">
<i class="fa-solid fa-file-pdf icon" aria-hidden="true"></i>
<span>PDF</span>
</a>
<a class="button" href="{{ download_svg_url }}">
<i class="fa-solid fa-vector-square icon" aria-hidden="true"></i>
<span>SVG</span>
</a>
<a class="button" href="{{ download_png_url }}">
<i class="fa-solid fa-file-image icon" aria-hidden="true"></i>
<span>PNG</span>
</a>
</div>
{% if error %}
@ -89,6 +107,7 @@
const runId = "{{ run_id }}"
const historySelect = document.getElementById("history-select")
const latexArea = document.getElementById("latex-source")
const compileWarning = document.getElementById("compile-warning")
const currentTex = latexArea ? latexArea.value : ""
let historyEntries = []
@ -110,7 +129,8 @@
const opt = document.createElement("option")
opt.value = String(idx)
const statusLabel = entry.status === "error" ? " (error)" : ""
opt.textContent = `${entry.action || "edit"} @ ${formatTs(entry.ts)}${statusLabel}`
const label = entry.summary && entry.summary.trim() ? entry.summary.trim() : entry.action || "edit"
opt.textContent = `${label} @ ${formatTs(entry.ts)}${statusLabel}`
frag.appendChild(opt)
})
@ -121,7 +141,7 @@
const loadHistory = async () => {
if (!historySelect) return
try {
const resp = await fetch(`${basePath}/${runId}/history`)
const resp = await fetch(`${basePath}/d/${runId}/history`)
if (!resp.ok) return
const data = await resp.json()
historyEntries = Array.isArray(data.entries) ? data.entries : []
@ -148,6 +168,12 @@
loadHistory()
}
if (compileWarning) {
setTimeout(() => {
compileWarning.style.display = "none"
}, 3500)
}
})()
</script>
</html>

@ -21,11 +21,11 @@
<form class="card" action="{{ base_path }}/convert" method="post" enctype="multipart/form-data">
<div class="field">
<label for="image">Diagram image</label>
<input id="image" name="image" type="file" accept="image/*" required />
<div class="hint">
Accepted: any format your browser can upload (PNG/JPG recommended). You can also paste an image
from the clipboard (Ctrl/Cmd+V).
</div>
<input id="image" class="sr-only" name="image" type="file" accept="image/*" required />
<label for="image" id="dropzone" class="dropzone"
>Click here to upload an image (or drag & drop / paste)</label
>
<div class="hint">Accepted: any format your browser can upload (PNG/JPG recommended).</div>
<div class="hint">Daily convert quota: {{ convert_remaining }}/{{ convert_limit }} left today.</div>
<div id="preview" class="preview" aria-live="polite">
@ -57,6 +57,7 @@
const submitStatus = document.getElementById("submit-status")
const convertBtn = document.getElementById("convert-btn")
const input = document.getElementById("image")
const dropzone = document.getElementById("dropzone")
const preview = document.getElementById("preview")
const img = document.getElementById("preview-img")
const meta = document.getElementById("preview-meta")
@ -107,6 +108,40 @@
showFile(file)
})
// Drag and drop support on label dropzone
dropzone.addEventListener("dragenter", e => {
e.preventDefault()
e.stopPropagation()
dropzone.classList.add("drag-over")
})
dropzone.addEventListener("dragleave", e => {
e.preventDefault()
e.stopPropagation()
dropzone.classList.remove("drag-over")
})
dropzone.addEventListener("dragover", e => {
e.preventDefault()
e.stopPropagation()
})
dropzone.addEventListener("drop", e => {
e.preventDefault()
e.stopPropagation()
dropzone.classList.remove("drag-over")
const files = e.dataTransfer.files
if (!files || files.length === 0) return
for (const file of files) {
if (file.type && file.type.startsWith("image/")) {
setInputFile(file)
return
}
}
})
window.addEventListener("paste", e => {
const cd = e.clipboardData
if (!cd || !cd.items) return

Loading…
Cancel
Save