Version. 0.1 Date. 2026-05-02 Provenance. Claude.ai CR drafting session. Operator: Marvin Percival. Status. Working draft. Untagged focused addition on main.
The Rendering room declares output formats but does not produce files. SHOW CONTENT displays the specialist's output in a panel. The Operator cannot hand anything to anyone — there is no downloadable artifact.
This CR adds render artifact download: a server-side materialization layer that converts render_content to the declared format and serves it as a file. Three materializers ship: PDF (via weasyprint), HTML, and Markdown. A DOWNLOAD button appears on each produced render card whose format has a registered materializer.
This completes the Rendering room's purpose. The room exists to deliver the finished artifact. Without a downloadable file in the declared format, the room is a viewer, not a delivery surface.
The specialist produces content — what the artifact says. Stored as render_content (JSONB). Materialization produces the file — what the artifact looks like as a deliverable in a specific format. This separation is consistent with executor opacity: the specialist's job is content; format conversion is infrastructure.
A mapping from render_format string to a materializer function. The download endpoint looks up the format, calls the materializer, returns the result as a file download. New formats are added by registering a new function — no endpoint changes.
# src/loomworks/rendering/materializers.py
from typing import Callable, NamedTuple
class MaterializedFile(NamedTuple):
content: bytes
content_type: str
filename: str
MaterializerFn = Callable # async (render_event, engagement_title) -> MaterializedFile
MATERIALIZERS: dict[str, MaterializerFn] = {}
def register_materializer(format_key: str, fn: MaterializerFn) -> None:
MATERIALIZERS[format_key] = fn
def get_materializer(format_key: str) -> MaterializerFn | None:
# Case-insensitive lookup
return MATERIALIZERS.get(format_key.upper())
PDF materializer. Wraps render_content in a print-optimized HTML template, converts via weasyprint. US Letter, 1-inch margins, Plex Serif headings, Inter body, engagement title in header, production date and provenance in footer, DUNIN7 line at base.
HTML materializer. Wraps render_content in a standalone HTML document with inline CSS. Same styling as the PDF template but served as .html. Useful for browser viewing without PDF conversion overhead.
Markdown materializer. Extracts text content from render_content and serves as .md. If render_content is a dict with a text/content field, extracts it. Minimal transformation — the content is already structured text from the specialist.
All three are registered at application startup.
Materialized files are generated on each download request. No caching, no stored binaries. The materialization is deterministic given the same render_content + template. Weasyprint on a 28K-char document is sub-second. If download volume grows, caching is a straightforward future addition.
Add weasyprint to the project's Python dependencies.
uv add weasyprint
Weasyprint requires system-level packages: cairo, pango, gdk-pixbuf. On macOS (DUNIN7-M4):
brew install cairo pango gdk-pixbuf libffi
Pre-flight verification: after install, confirm python -c "import weasyprint; print(weasyprint.__version__)" succeeds.
Create src/loomworks/rendering/materializers.py containing:
MaterializedFile named tuple (content bytes, content_type string, filename string).MATERIALIZERS registry dict.register_materializer() and get_materializer() functions.materialize_pdf() — reads render_content, extracts text/HTML content, wraps in the PDF template (§3.3), converts via weasyprint.HTML(string=...).write_pdf(). Returns MaterializedFile with content_type="application/pdf".materialize_html() — wraps content in standalone HTML with inline CSS (same visual treatment as the PDF template). Returns MaterializedFile with content_type="text/html".materialize_md() — extracts text from render_content. If the content is a dict with a key like "content" or "text", extracts the string value. Returns MaterializedFile with content_type="text/markdown"._build_filename() — helper that produces {render-type-name}--{engagement-title}--{date}.{ext}, slugified (lowercase, hyphens for spaces, strip non-alphanumeric). Shared across materializers.register_all_materializers() — registers PDF, HTML, MD. Called at app startup.
Content extraction logic. render_content is stored as JSONB (dict). The specialist's content may be under a key like "content", "text", "html", or the dict itself may be the content structure. The extraction helper should:
render_content is a dict with a single string value, use that value.render_content is a dict with a "content" key, use that.render_content is a dict with an "html" key, use that.
CC should inspect the actual render_content structure on the Goosey engagement's produced renders to determine the real key layout, and adjust the extraction accordingly.
The HTML template wrapped around content before weasyprint conversion. Inline CSS (no external stylesheets — weasyprint processes the entire document in one pass).
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Serif:wght@400;600&family=Inter:wght@400;500;600&display=swap');
@page {
size: letter;
margin: 1in;
@top-center {
content: "ENGAGEMENT_TITLE — RENDER_TYPE_NAME";
font-family: 'Inter', sans-serif;
font-size: 8pt;
color: #888;
}
@bottom-left {
content: "Produced PRODUCTION_DATE";
font-family: 'Inter', sans-serif;
font-size: 7pt;
color: #999;
}
@bottom-right {
content: "Page " counter(page) " of " counter(pages);
font-family: 'Inter', sans-serif;
font-size: 7pt;
color: #999;
}
}
body {
font-family: 'Inter', sans-serif;
font-size: 11pt;
line-height: 1.5;
color: #2A2520;
}
h1, h2, h3 {
font-family: 'IBM Plex Serif', serif;
font-weight: 600;
color: #2A2520;
}
h1 { font-size: 20pt; margin-top: 0; }
h2 { font-size: 16pt; }
h3 { font-size: 13pt; }
ul, ol { padding-left: 1.5em; }
li { margin-bottom: 0.3em; }
.lw-footer {
margin-top: 2em;
padding-top: 0.5em;
border-top: 1px solid #ccc;
font-size: 8pt;
color: #999;
text-align: center;
}
</style>
</head>
<body>
CONTENT_PLACEHOLDER
<div class="lw-footer">DUNIN7 — Done In Seven LLC — Miami, Florida</div>
</body>
</html>
The template is a Python string with placeholder substitution — not Jinja. The placeholders (ENGAGEMENT_TITLE, RENDER_TYPE_NAME, PRODUCTION_DATE, CONTENT_PLACEHOLDER) are replaced by the materializer before passing to weasyprint.
Font loading note. Weasyprint loads fonts via fontconfig. If Google Fonts are not available on the system, weasyprint falls back to system serif/sans-serif. This is acceptable — the template declares the preferred fonts; the output degrades gracefully. CC should verify on DUNIN7-M4 that the fonts render. If not, install them locally:
# If Google Fonts don't load via URL in weasyprint
brew install --cask font-ibm-plex-serif font-inter
Add to the renders router (src/loomworks/api/routers/renders.py):
GET /engagements/{eid}/renders/{render_id}/download
Query params: format (optional) — override the declared format
Returns: binary file with Content-Disposition: attachment
Authorization: engagement member (any designation)
Errors:
- 404 if render not found or wrong engagement
- 422 if format has no registered materializer
- 422 if render is in retired or invalidated state
Implementation:
render_events_view by object_id and engagement_id.retired or invalidated (422 with message).render_format from the render.get_materializer(format).Response(content=result.content, media_type=result.content_type, headers={"Content-Disposition": f'attachment; filename="{result.filename}"'}).Add a lightweight endpoint:
GET /engagements/{eid}/renders/supported-formats
Returns: { "formats": ["PDF", "HTML", "MD"] }
Authorization: engagement member
The frontend uses this to gate the DOWNLOAD button. This keeps the frontend decoupled from which materializers are registered — new formats appear automatically.
Write tests/test_render_download.py:
application/pdf content type and non-empty body.text/html content type and content contains the template wrapper.text/markdown content type.10 tests.
In the Render row component (inside expanded DeclaredRenderType cards), add a DOWNLOAD button next to SHOW CONTENT and RETIRE.
Visibility rule: DOWNLOAD appears only when the render's format has a registered materializer. On component mount (or on Rendering room load), fetch GET /engagements/{eid}/renders/supported-formats and store the list. The button renders only if supportedFormats.includes(render.render_format.toUpperCase()).
Button style: secondary button (2px border, type-metal border on cartridge ground per Phase 23/27 contextual color rules). Label: DOWNLOAD. Positioned before RETIRE in the button row.
Behavior: on click, construct the download URL GET /engagements/{eid}/renders/${renderId}/download and trigger a browser download. Use an anchor element with download attribute, or window.open() to the URL. The browser handles the file save dialog.
Loading state: while the download is in flight (PDF generation may take a moment), the button shows "Downloading..." and is disabled. On completion or error, it resets. Errors display inline on the render row.
Add to / update the Rendering room tests:
3 tests.
Auto-mode posture: Steps 0–4 auto, Checkpoint A at the end.
Step 0 — System dependency verification.
Verify weasyprint system dependencies on DUNIN7-M4:
brew install cairo pango gdk-pixbuf libffi
uv add weasyprint
python -c "import weasyprint; print(weasyprint.__version__)"
If the import fails, resolve before proceeding.
Inspect the Goosey engagement's produced render(s) to determine the actual render_content key structure:
uv run python -c "
import asyncio
from loomworks.db import get_session
from sqlalchemy import text
async def check():
async for db in get_session():
result = await db.execute(text(
'SELECT object_id, render_format, render_content '
'FROM render_events_view LIMIT 3'
))
for row in result:
print(f'ID: {row[0]}')
print(f'Format: {row[1]}')
content = row[2]
if isinstance(content, dict):
print(f'Keys: {list(content.keys())}')
for k, v in content.items():
print(f' {k}: {type(v).__name__} len={len(str(v))}')
print()
asyncio.run(check())
"
Record the key structure. Use it to implement the content extraction helper in Step 1.
Commit: Render download step 0: weasyprint dependency and content inspection.
Step 1 — Materializer module and PDF template.
Create src/loomworks/rendering/materializers.py per §3.2. Create the PDF template per §3.3. Implement all three materializers (PDF, HTML, MD). Implement register_all_materializers(). Call it from application startup (in main.py or the app factory).
Verify PDF generation works standalone:
uv run python -c "
import weasyprint
html = '<html><body><h1>Test</h1><p>Hello world</p></body></html>'
pdf = weasyprint.HTML(string=html).write_pdf()
with open('/tmp/test-render.pdf', 'wb') as f:
f.write(pdf)
print(f'PDF generated: {len(pdf)} bytes')
"
Open /tmp/test-render.pdf and verify it renders correctly.
Commit: Render download step 1: materializer registry and three materializers.
Step 2 — Download endpoint and supported-formats endpoint.
Add the two endpoints to src/loomworks/api/routers/renders.py per §3.4 and §3.5. Register the materializers at app startup if not already done in Step 1.
Commit: Render download step 2: download and supported-formats endpoints.
Step 3 — Substrate tests.
Write tests/test_render_download.py per §3.6. Run full test suite.
Commit: Render download step 3: download endpoint tests.
Step 4 — Frontend: DOWNLOAD button.
Implement per §4.1. Add component tests per §4.2. Run lint + tsc + build + test clean.
Commit (frontend repo): Render download step 4: download button on render cards.
Checkpoint A — Final. Both repos green. Operator tests PDF download on Goosey. Opens the PDF and confirms it looks right.
This CR is accepted when:
docx-js or python-docx server-side. Future materializer registration.
Read the Change Request document at the path I supply below. This CR
adds render artifact download to the Rendering room — the capability
that completes the room's purpose.
CR path: ~/Downloads/render-download-cr-v0_1.md
Three materializers: PDF (weasyprint), HTML, and Markdown. Server-side
generation, on-demand. A DOWNLOAD button on each produced render card.
Step 0: install weasyprint system deps, add the Python package, inspect
the actual render_content structure on Goosey's renders.
Steps 0–4 auto, Checkpoint A halts for Operator to test the PDF.
Key points:
- Materializer registry pattern — new formats added by registering
a function, no endpoint changes needed.
- PDF template: US Letter, 1-inch margins, Plex Serif headings,
Inter body, engagement title header, DUNIN7 footer.
- DOWNLOAD button gated on supported-formats endpoint response.
- Retired/invalidated renders: no download.
- Content extraction: inspect actual render_content keys at Step 0
before implementing the extraction helper.
Substrate baseline: ~1250 tests, 2 skips.
Frontend baseline: 217+ vitest, lint + tsc + build + test clean.
DUNIN7 — Done In Seven LLC — Miami, Florida Render artifact download — CR v0.1 — 2026-05-02