`, so the preview is pixel-perfect against the live article. The `article_preview` POST endpoint uses the same pattern for HTMX live updates. Removed ~65 lines of redundant `.preview-body` custom CSS from the editor template.
+
### Changed
- **Semantic compression pass** — applied Casey Muratori's compression workflow (write concrete → observe patterns → compress genuine repetitions) across all three packages. Net result: ~200 lines removed, codebase simpler.
- **`count_where()` helper** (`web/core.py`): compresses the `fetch_one("SELECT COUNT(*) ...") + null-check` pattern. Applied across 30+ call sites in admin, suppliers, directory, dashboard, public, and planner routes. Dashboard stats function shrinks from 75 to 25 lines.
diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py
index 106174d..5bbdeed 100644
--- a/web/src/padelnomics/admin/routes.py
+++ b/web/src/padelnomics/admin/routes.py
@@ -2458,11 +2458,11 @@ async def article_new():
if not title or not body:
await flash("Title and body are required.", "error")
- return await render_template("admin/article_form.html", data=dict(form), editing=False)
+ return await render_template("admin/article_form.html", data=dict(form), editing=False, preview_doc="")
if is_reserved_path(url_path):
await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error")
- return await render_template("admin/article_form.html", data=dict(form), editing=False)
+ return await render_template("admin/article_form.html", data=dict(form), editing=False, preview_doc="")
# Render markdown → HTML with scenario + product cards baked in
body_html = mistune.html(body)
@@ -2495,7 +2495,7 @@ async def article_new():
await flash(f"Article '{title}' created.", "success")
return redirect(url_for("admin.articles"))
- return await render_template("admin/article_form.html", data={}, editing=False, body_html="")
+ return await render_template("admin/article_form.html", data={}, editing=False, preview_doc="")
@bp.route("/articles/
/edit", methods=["GET", "POST"])
@@ -2531,7 +2531,7 @@ async def article_edit(article_id: int):
if is_reserved_path(url_path):
await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error")
return await render_template(
- "admin/article_form.html", data=dict(form), editing=True, article_id=article_id,
+ "admin/article_form.html", data=dict(form), editing=True, article_id=article_id, preview_doc="",
)
# Re-render if body provided
@@ -2576,6 +2576,13 @@ async def article_edit(article_id: int):
body = raw[m.end():].lstrip("\n") if m else raw
body_html = mistune.html(body) if body else ""
+ css_url = url_for("static", filename="css/output.css")
+ preview_doc = (
+ f""
+ f" "
+ f""
+ f"{body_html}
"
+ ) if body_html else ""
data = {**dict(article), "body": body}
return await render_template(
@@ -2583,7 +2590,7 @@ async def article_edit(article_id: int):
data=data,
editing=True,
article_id=article_id,
- body_html=body_html,
+ preview_doc=preview_doc,
)
@@ -2592,13 +2599,19 @@ async def article_edit(article_id: int):
@csrf_protect
async def article_preview():
"""Render markdown body to HTML for the live editor preview panel."""
- from markupsafe import Markup
form = await request.form
body = form.get("body", "")
m = _FRONTMATTER_RE.match(body)
body = body[m.end():].lstrip("\n") if m else body
- body_html = Markup(mistune.html(body)) if body else ""
- return await render_template("admin/partials/article_preview.html", body_html=body_html)
+ body_html = mistune.html(body) if body else ""
+ css_url = url_for("static", filename="css/output.css")
+ preview_doc = (
+ f""
+ f" "
+ f""
+ f"{body_html}
"
+ ) if body_html else ""
+ return await render_template("admin/partials/article_preview.html", preview_doc=preview_doc)
@bp.route("/articles//delete", methods=["POST"])
diff --git a/web/src/padelnomics/admin/templates/admin/article_form.html b/web/src/padelnomics/admin/templates/admin/article_form.html
index e488873..ae2b9f0 100644
--- a/web/src/padelnomics/admin/templates/admin/article_form.html
+++ b/web/src/padelnomics/admin/templates/admin/article_form.html
@@ -195,84 +195,50 @@
.ae-editor::placeholder { color: #CBD5E1; }
.ae-editor:focus { outline: none; }
- /* Preview pane */
- .ae-preview {
+ /* Preview pane — iframe fills the content area */
+ #ae-preview-content {
flex: 1;
- overflow-y: auto;
- padding: 1.5rem 2rem;
- background: #fff;
+ display: flex;
+ min-height: 0;
}
- /* Preview typography — maps rendered markdown */
- .ae-preview .preview-body { max-width: 42rem; }
- .ae-preview .preview-body h1 {
- font-family: var(--font-display);
- font-size: 1.625rem; font-weight: 700;
- color: #0F172A; margin: 0 0 1rem;
- line-height: 1.25;
- }
- .ae-preview .preview-body h2 {
- font-family: var(--font-display);
- font-size: 1.25rem; font-weight: 600;
- color: #0F172A; margin: 1.75rem 0 0.625rem;
- }
- .ae-preview .preview-body h3 {
- font-size: 1.0625rem; font-weight: 600;
- color: #0F172A; margin: 1.25rem 0 0.5rem;
- }
- .ae-preview .preview-body h4 {
- font-size: 0.9375rem; font-weight: 600;
- color: #334155; margin: 1rem 0 0.375rem;
- }
- .ae-preview .preview-body p { margin: 0 0 0.875rem; color: #1E293B; line-height: 1.75; }
- .ae-preview .preview-body ul,
- .ae-preview .preview-body ol { margin: 0 0 0.875rem 1.375rem; color: #1E293B; }
- .ae-preview .preview-body li { margin: 0.3rem 0; line-height: 1.65; }
- .ae-preview .preview-body code {
- font-family: var(--font-mono); font-size: 0.8125rem;
- background: #F1F5F9; color: #1D4ED8;
- padding: 0.1rem 0.35rem; border-radius: 3px;
- }
- .ae-preview .preview-body pre {
- background: #0F172A; color: #CBD5E1;
- padding: 1rem 1.125rem; border-radius: 6px;
- overflow-x: auto; margin: 0 0 0.875rem;
- font-size: 0.8125rem; line-height: 1.65;
- }
- .ae-preview .preview-body pre code {
- background: none; color: inherit; padding: 0;
- }
- .ae-preview .preview-body blockquote {
- border-left: 3px solid #1D4ED8;
- padding-left: 1rem; margin: 0 0 0.875rem;
- color: #475569;
- }
- .ae-preview .preview-body a { color: #1D4ED8; }
- .ae-preview .preview-body hr {
- border: none; border-top: 1px solid #E2E8F0;
- margin: 1.5rem 0;
- }
- .ae-preview .preview-body strong { font-weight: 600; color: #0F172A; }
- .ae-preview .preview-body table {
- width: 100%; border-collapse: collapse;
- font-size: 0.875rem; margin: 0 0 0.875rem;
- }
- .ae-preview .preview-body th {
- background: #F8FAFC; font-weight: 600;
- padding: 0.5rem 0.75rem; text-align: left;
- border: 1px solid #E2E8F0; color: #0F172A;
- }
- .ae-preview .preview-body td {
- padding: 0.5rem 0.75rem;
- border: 1px solid #E2E8F0; color: #1E293B;
- }
- .ae-preview .preview-body tr:nth-child(even) td { background: #F8FAFC; }
-
.preview-placeholder {
font-size: 0.875rem;
color: #94A3B8;
font-style: italic;
- margin: 0;
+ margin: 1.5rem 2rem;
+ }
+
+ /* Collapsible metadata */
+ .ae-meta--collapsed { display: none; }
+
+ .ae-toolbar__toggle {
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: #64748B;
+ background: none;
+ border: 1px solid #E2E8F0;
+ border-radius: 4px;
+ padding: 0.25rem 0.6rem;
+ cursor: pointer;
+ flex-shrink: 0;
+ }
+ .ae-toolbar__toggle:hover { color: #0F172A; border-color: #94A3B8; }
+
+ /* Word count footer */
+ .ae-footer {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ padding: 0.25rem 0.875rem;
+ background: #F8FAFC;
+ border-top: 1px solid #E2E8F0;
+ flex-shrink: 0;
+ }
+ .ae-wordcount {
+ font-size: 0.625rem;
+ font-family: var(--font-mono);
+ color: #94A3B8;
}
/* HTMX loading indicator — htmx toggles .htmx-request on the element */
@@ -308,6 +274,8 @@
{{ data.get('status', 'draft') }}
{% endif %}
+ Meta ▾
{% if editing %}Save Changes{% else %}Create Article{% endif %}
@@ -400,6 +368,9 @@
hx-include="[name=csrf_token]"
hx-indicator="#ae-loading"
>{{ data.get('body', '') }}
+
@@ -408,14 +379,17 @@
Start writing to see a preview.
{% endif %}