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 7 months ago
parent 763ec9e384
commit fcc9b086db

@ -1,4 +1,5 @@
import base64 import base64
import hashlib
import json import json
import logging import logging
import mimetypes import mimetypes
@ -78,6 +79,7 @@ class HistoryEntry(TypedDict, total=False):
instructions: str instructions: str
model: str model: str
status: str status: str
summary: str
RUNS_DIR.mkdir(parents=True, exist_ok=True) 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", ""), "instructions": entry.get("instructions", ""),
"model": entry.get("model", ""), "model": entry.get("model", ""),
"status": entry.get("status", ""), "status": entry.get("status", ""),
"summary": entry.get("summary", ""),
} }
path = _history_path(run_dir) path = _history_path(run_dir)
with path.open("a", encoding="utf-8") as f: 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", ""), "instructions": obj.get("instructions", ""),
"model": obj.get("model", ""), "model": obj.get("model", ""),
"status": obj.get("status", ""), "status": obj.get("status", ""),
"summary": obj.get("summary", ""),
} }
) )
except Exception: except Exception:
@ -271,10 +275,11 @@ CONVERT_PROMPT = (
EDIT_SYSTEM_PROMPT = ( EDIT_SYSTEM_PROMPT = (
"You are an expert LaTeX/TikZ editor. " "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. " "Given an existing standalone LaTeX/TikZ document and an edit request, return EXACTLY TWO elements: "
"Return ONLY raw LaTeX source: no markdown, no code fences, no commentary, no extra text. " "a complete LaTeX document wrapped in <latex>...</latex> and a very generic summary (less than 7 words, all lowercase) wrapped in <summary>...</summary>. "
"The output must compile with pdflatex. " "Return ONLY these tags and their contents: no markdown, no code fences, no commentary, no extra text. "
"Wrap LaTeX to roughly 80 characters per line, keep formatting tidy, and add brief LaTeX comments to clarify sections when helpful." "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 = ( EDIT_PROMPT_TEMPLATE = (
@ -282,7 +287,7 @@ EDIT_PROMPT_TEMPLATE = (
"Rules:\n" "Rules:\n"
"- Return a complete standalone LaTeX document (\\documentclass{{standalone}} ... \\end{{document}}).\n" "- Return a complete standalone LaTeX document (\\documentclass{{standalone}} ... \\end{{document}}).\n"
"- Make minimal changes necessary to satisfy the instructions.\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" "INSTRUCTIONS:\n{instructions}\n\n"
"LATEX:\n{latex}\n" "LATEX:\n{latex}\n"
) )
@ -326,6 +331,22 @@ def _extract_text_from_litellm(resp: Any) -> str:
return str(resp).strip() 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: def _litellm_call(*, model: str, messages: list[dict[str, Any]], **kwargs: Any) -> Any:
return completion(model=model, messages=messages, **kwargs) return completion(model=model, messages=messages, **kwargs)
@ -439,6 +460,46 @@ def _render_png_with_magick(
return None 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( def _render_svg_with_pdf2svg(
run_dir: Path, pdf_path: Path, run_logger: RunLogger, section_title: str, svg_name: str = "diagram.svg" run_dir: Path, pdf_path: Path, run_logger: RunLogger, section_title: str, svg_name: str = "diagram.svg"
) -> Optional[Path]: ) -> Optional[Path]:
@ -468,7 +529,27 @@ def _compile_latex_to_png(
def _compile_phase( def _compile_phase(
run_dir: Path, tex_path: Path, run_log: RunLogger, phase: str, start_msg: str = "compile.start" 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) run_log.line(start_msg)
started = time.monotonic() started = time.monotonic()
pdf_path, png_path = _compile_latex_to_png(run_dir, tex_path, run_log, phase=phase) 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 else None
) )
elapsed_ms = int((time.monotonic() - started) * 1000) elapsed_ms = int((time.monotonic() - started) * 1000)
_save_compile_state(
run_dir,
{
"last_ts": time.time(),
"last_hash": digest,
},
)
run_log.line( 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}" 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: 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: 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 png_path: Optional[Path] = None
svg_path: Optional[Path] = None svg_path: Optional[Path] = None
cp_elapsed_ms = 0
cp_reason = ""
if tex: 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" run_dir, tex_path, run_log, phase="convert"
) )
logger.info( 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}", 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) @app.get("/d/{run_id}", response_class=HTMLResponse)
async def get_run_result(request: Request, run_id: str): async def get_run_result(request: Request, run_id: str, warn: str | None = None):
run_dir = _run_dir_for_id(run_id) run_dir = _run_dir_for_id(run_id)
if run_dir is None: if run_dir is None:
return HTMLResponse("Not found", status_code=404) 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_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") 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( return templates.TemplateResponse(
"edit.html", "edit.html",
{ {
@ -783,11 +898,12 @@ async def get_run_result(request: Request, run_id: str):
"base_path": BASE_PATH, "base_path": BASE_PATH,
"edit_remaining": rate_status["edit_remaining"], "edit_remaining": rate_status["edit_remaining"],
"edit_limit": rate_status["edit_limit"], "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(...)): async def edit_tex(request: Request, run_id: str, instructions: str = Form(...), latex: str = Form(...)):
run_dir = _run_dir_for_id(run_id) run_dir = _run_dir_for_id(run_id)
if run_dir is None: 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() instructions = (instructions or "").strip()
if not instructions: if not instructions:
_write_run_error(run_dir, "Edit instructions are empty.") _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" tex_path = run_dir / "diagram.tex"
current_tex = latex current_tex = latex
@ -825,6 +941,7 @@ async def edit_tex(request: Request, run_id: str, instructions: str = Form(...),
raw_text = "" raw_text = ""
new_tex = "" new_tex = ""
used_model = "" used_model = ""
summary_text = ""
try: try:
llm_started = time.monotonic() llm_started = time.monotonic()
run_log.line("llm.start") 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, 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) llm_elapsed_ms = int((time.monotonic() - llm_started) * 1000)
logger.info( logger.info(
"edit.llm.ok run_id=%s elapsed_ms=%s raw_chars=%s tex_chars=%s", "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 png_path: Optional[Path] = None
svg_path: Optional[Path] = None svg_path: Optional[Path] = None
cp_elapsed_ms = 0
cp_reason = ""
if new_tex: 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( logger.info(
"edit.compile.done run_id=%s elapsed_ms=%s ok_pdf=%s ok_png=%s ok_svg=%s", "edit.compile.done run_id=%s elapsed_ms=%s ok_pdf=%s ok_png=%s ok_svg=%s",
run_id, run_id,
@ -872,19 +994,21 @@ async def edit_tex(request: Request, run_id: str, instructions: str = Form(...),
svg_path is not None, svg_path is not None,
) )
history_status = "ok" if pdf_path else "error" if cp_elapsed_ms > 0:
_append_history_entry( history_status = "ok" if pdf_path else "error"
run_dir, _append_history_entry(
{ run_dir,
"ts": time.time(), {
"ip": client_ip, "ts": time.time(),
"action": "edit", "ip": client_ip,
"latex": new_tex, "action": "edit",
"instructions": instructions, "latex": new_tex,
"model": used_model or (EDIT_MODELS[0] if EDIT_MODELS else ""), "instructions": instructions,
"status": history_status, "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 "") 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}", 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(...)): async def compile_tex(request: Request, run_id: str, latex: str = Form(...)):
run_dir = _run_dir_for_id(run_id) run_dir = _run_dir_for_id(run_id)
if run_dir is None: 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 png_path: Optional[Path] = None
svg_path: Optional[Path] = None svg_path: Optional[Path] = None
cp_elapsed_ms = 0
cp_reason = ""
if tex.strip(): 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" run_dir, tex_path, run_log, phase="compile", start_msg="compile.invoke"
) )
logger.info( logger.info(
@ -977,19 +1105,20 @@ async def compile_tex(request: Request, run_id: str, latex: str = Form(...)):
svg_path is not None, svg_path is not None,
) )
history_status = "ok" if pdf_path else "error" if cp_elapsed_ms > 0:
_append_history_entry( history_status = "ok" if pdf_path else "error"
run_dir, _append_history_entry(
{ run_dir,
"ts": time.time(), {
"ip": client_ip, "ts": time.time(),
"action": "compile", "ip": client_ip,
"latex": tex, "action": "compile",
"instructions": "", "latex": tex,
"model": "", "instructions": "",
"status": history_status, "model": "",
}, "status": history_status,
) },
)
meta = _read_run_meta(run_dir) meta = _read_run_meta(run_dir)
model_label = _get_meta_string(meta, "model") 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) logger.info("compile.end run_id=%s elapsed_ms=%s", run_id, elapsed_ms)
run_log.line(f"compile.finish elapsed_ms={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): async def get_history(request: Request, run_id: str):
run_dir = _run_dir_for_id(run_id) run_dir = _run_dir_for_id(run_id)
if run_dir is None: if run_dir is None:
@ -1051,6 +1181,7 @@ async def get_history(request: Request, run_id: str):
"instructions": entry.get("instructions", ""), "instructions": entry.get("instructions", ""),
"model": entry.get("model", ""), "model": entry.get("model", ""),
"status": entry.get("status", ""), "status": entry.get("status", ""),
"summary": entry.get("summary", ""),
} }
) )

@ -6,155 +6,152 @@
*::after { *::after {
box-sizing: border-box; box-sizing: border-box;
} }
html {
html, height: 100%;
color-scheme: light dark;
}
body { body {
height: 100%; height: 100%;
} }
body { body {
margin: 0; margin: 0;
} }
img { img {
max-width: 100%; max-width: 100%;
height: auto; height: auto;
display: block; display: block;
} }
textarea,
textarea { input,
select,
button {
font: inherit; font: inherit;
} }
} }
@layer base { @layer base {
:root { :root {
color-scheme: light dark; --bg: #e0e0e0;
--fg: #121212;
--border: 1px solid rgba(127, 127, 127, 0.35); --border-color: #000000;
--border-danger: 1px solid rgba(255, 0, 0, 0.35);
/* Neobrutalist Palette */
--radius-lg: 0.75rem; --c-primary: #a390e4; /* Purple */
--radius-md: 0.625rem; --c-secondary: #90e4c1; /* Mint */
--c-accent: #ffc900; /* Yellow */
--pad-1: 0.5rem; --c-danger: #ff6b6b; /* Red */
--pad-2: 0.75rem; --c-surface: #ffffff;
--pad-3: 1rem;
--pad-4: 1.5rem; --border-width: 2px;
--border: var(--border-width) solid var(--border-color);
--text-dim: 0.78; --shadow: 4px 4px 0 0 var(--border-color);
--shadow-sm: 2px 2px 0 0 var(--border-color);
--btn-bg: #f2f3f5; --shadow-hover: 6px 6px 0 0 var(--border-color);
--btn-bg-hover: #e7e8ec;
--btn-bg-active: #d9dbe0; --pad-xs: 0.25rem;
--btn-border: #d0d4db; --pad-sm: 0.5rem;
--btn-text: #20242c; --pad-md: 0.75rem;
--pad-lg: 1.5rem;
--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; --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) { @media (prefers-color-scheme: dark) {
:root { :root {
--btn-bg: #2d3138; /* Gruvbox Dark Palette */
--btn-bg-hover: #343944; --bg: #1d2021;
--btn-bg-active: #2a2e35; --fg: #ebdbb2;
--btn-border: #3c414c; --border-color: #504945;
--btn-text: #e8ecf2;
--c-primary: #d3869b; /* Gruvbox Purple */
--c-secondary: #8ec07c; /* Gruvbox Green */
--c-accent: #fabd2f; /* Gruvbox Yellow */
--c-danger: #fb4934; /* Gruvbox Red */
--c-surface: #282828;
} }
} }
body { body {
font-family: var(--sans); font-family: var(--font-sans);
min-height: 100vh; 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 { code,
font-family: var(--mono); pre,
font-size: 0.95em; textarea,
.mono {
font-family: var(--font-mono);
}
h1,
h2,
h3 {
margin: 0;
font-weight: 900;
text-transform: uppercase;
letter-spacing: -0.02em;
} }
} }
@layer components { @layer components {
.wrap { .wrap {
max-width: 54rem; max-width: 50rem;
margin-inline: auto; margin: 0 auto;
padding: var(--pad-4); padding: var(--pad-lg);
}
.label {
font-size: 0.75rem;
opacity: var(--text-dim);
font-weight: 600;
}
.hint {
font-size: 0.75rem;
opacity: var(--text-dim);
} }
.card { .card {
background: var(--c-surface);
border: var(--border); border: var(--border);
border-radius: var(--radius-lg); box-shadow: var(--shadow);
padding: var(--pad-3); padding: var(--pad-md);
}
.field {
display: grid; display: grid;
grid-template-rows: auto 1fr; gap: var(--pad-md);
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;
}
} }
.btn,
button, button,
a.button, a.button {
.btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0.625rem 0.875rem; padding: var(--pad-sm) var(--pad-md);
border-radius: var(--radius-md); background: var(--c-primary);
border: 1px solid var(--btn-border); color: oklch(from var(--c-primary) calc(1 - l) c h);
background: var(--btn-bg); border: var(--border);
cursor: pointer; box-shadow: var(--shadow-sm);
font-weight: 800;
text-decoration: none; text-decoration: none;
color: var(--btn-text); cursor: pointer;
gap: 0.4rem; transition: all 0.1s;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5), inset 0 -1px 0 rgba(0, 0, 0, 0.125); gap: 0.5rem;
font-size: 0.85rem;
text-transform: uppercase;
&:hover { &:hover {
background: var(--btn-bg-hover); transform: translate(-2px, -2px);
box-shadow: var(--shadow);
filter: brightness(1.1);
} }
&:active { &: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 { .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 { .error {
border: var(--border-danger); background: var(--c-danger);
border-radius: var(--radius-lg); color: var(--fg);
padding: var(--pad-3); border: var(--border);
padding: var(--pad-sm);
font-weight: bold;
box-shadow: var(--shadow);
white-space: pre-wrap; 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 { @layer pages {
/* Index page */ /* Index page */
.page-index { .page-index {
display: grid;
place-items: center;
min-height: 100vh;
.wrap { .wrap {
display: grid; width: 100%;
gap: 1rem;
} }
h1 { h1 {
font-size: 1.25rem; background: var(--c-secondary);
margin: 0; border: var(--border);
box-shadow: var(--shadow);
padding: var(--pad-sm);
text-align: center;
margin-bottom: var(--pad-md);
font-size: 1.5rem;
} }
p { p {
margin: 0; background: var(--c-surface);
opacity: 0.88; border: var(--border);
line-height: 1.45; padding: var(--pad-sm);
} margin-bottom: var(--pad-md);
font-size: 0.9rem;
form { font-weight: 500;
display: grid;
gap: 0.9rem;
} }
.preview { .preview {
display: none; display: none;
margin-top: 0.75rem; margin-top: var(--pad-sm);
padding: var(--pad-2); padding: var(--pad-sm);
border-radius: var(--radius-lg);
border: var(--border); border: var(--border);
background: var(--bg);
img { img {
border-radius: var(--radius-md); border: var(--border);
} }
.meta { .meta {
margin-top: 0.5rem; margin-top: var(--pad-xs);
font-family: var(--font-mono);
font-size: 0.75rem; font-size: 0.75rem;
opacity: var(--text-dim); border-top: var(--border);
word-break: break-word; padding-top: var(--pad-xs);
} }
} }
} }
@ -219,146 +326,172 @@
.page-result { .page-result {
display: grid; display: grid;
grid-template-rows: auto 1fr; grid-template-rows: auto 1fr;
min-height: 100vh;
.topbar { .topbar {
display: flex; display: flex;
gap: 0.75rem; gap: var(--pad-md);
align-items: center; align-items: center;
padding: var(--pad-2) var(--pad-3); padding: var(--pad-sm) var(--pad-md);
border-bottom: var(--border); border-bottom: var(--border);
background: var(--c-surface);
z-index: 10;
a { a {
text-decoration: none; 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 { .container {
min-height: 0;
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 40% 60%;
height: 100%;
} }
.pane { .pane {
min-width: 0; padding: var(--pad-md);
padding: var(--pad-3); overflow-y: auto;
} display: flex;
flex-direction: column;
align-items: start;
gap: var(--pad-md);
height: 100%;
.left { &.left {
border-right: var(--border); border-right: var(--border);
display: grid; background: var(--c-surface);
grid-template-rows: auto 1fr; }
gap: 0.75rem;
&.right {
background: var(--bg);
}
} }
.edit-form { .edit-form {
display: flex;
flex-direction: column;
gap: var(--pad-md);
flex: 1;
width: 100%;
height: 100%;
min-height: 0; min-height: 0;
display: grid;
grid-template-rows: auto 1fr;
gap: 0.75rem;
.prompt-row { .field {
display: flex; display: flex;
gap: 0.5rem; flex-direction: column;
align-items: stretch; gap: 0.25rem;
input[type="text"] { &:has(textarea) {
flex: 1; flex: 1;
min-width: 0; min-height: 0;
}
.btn {
white-space: nowrap;
} }
} }
.actions { .prompt-row {
display: flex; display: flex;
flex-wrap: wrap; gap: var(--pad-sm);
gap: 0.5rem;
align-items: center; input {
flex: 1;
}
button {
white-space: nowrap;
}
} }
textarea { 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 { .actions {
width: 100%; display: flex;
height: 100%; gap: var(--pad-sm);
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;
} }
.preview { .preview {
min-height: 15rem;
display: grid; display: grid;
place-items: center; place-items: center;
border-radius: var(--radius-lg);
border: var(--border); 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; overflow: auto;
box-shadow: var(--shadow);
img {
max-width: 100%;
}
} }
.downloads { .downloads {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: var(--pad-sm);
}
.error { .button {
font-family: var(--mono); font-size: 0.75rem;
font-size: 0.85rem; 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) { @media (max-width: 60rem) {
.container { .container {
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: 1fr auto;
} }
.pane.left {
.left {
border-right: none; border-right: none;
border-bottom: var(--border); border-bottom: var(--border);
} }
}
@media (max-width: 40rem) {
.topbar { .topbar {
flex-wrap: wrap; .label {
gap: 0.5rem; display: none;
padding: var(--pad-2) var(--pad-2); }
}
.topbar .label {
display: none;
} }
} }
} }

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

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

Loading…
Cancel
Save