diff --git a/main.py b/main.py
index df811d8..25dfdea 100644
--- a/main.py
+++ b/main.py
@@ -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 ... and a very generic summary (less than 7 words, all lowercase) wrapped in .... "
+ "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 ... and ... from raw_text.
+
+ Falls back to treating the whole raw_text as LaTeX if tags are missing.
+ """
+ latex_re = re.compile(r"(.*?)", re.DOTALL | re.IGNORECASE)
+ summary_re = re.compile(r"(.*?)", 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", ""),
}
)
diff --git a/public/styles.css b/public/styles.css
index aa91949..b99a2b9 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -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;
+ }
}
}
}
diff --git a/templates/edit.html b/templates/edit.html
index 19b3448..84748a1 100644
--- a/templates/edit.html
+++ b/templates/edit.html
@@ -17,7 +17,7 @@
{% 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)
+ }
})()