This document traces the evolution of canvas export functionality in QNTX, from the original use case to the current implementation and future direction.
Problem: QNTX learns from user patterns and generates LLM-augmented Bluesky posts. These posts should include a link to "the canvas as it was at that moment" for transparency and provenance.
Requirements:
Example flow:
QNTX detects pattern in user's work
↓
LLM generates insight: "You're building a recursive type system"
↓
Server creates Bluesky post:
"You're building a recursive type system 🧠
Canvas snapshot: https://ipfs.io/ipfs/Qm..."
↓
Link shows frozen canvas state - notes, code, connections
This requires server-side rendering because the server is creating the post.
Branch: claude/canvas-static-export-hjzI5 (PR #600)
Approach:
Files:
glyph/handlers/canvas_export.go - Go HTML builderglyph/handlers/canvas_publish.go - IPFS + git publishingProblems:
Status: Superseded by client-side approach, but ideas (IPFS publishing) still valuable.
Branch: claude/canvas-dom-export (merged to main)
Approach:
Files:
web/ts/api/canvas-export.ts - DOM capture + CSS extractionweb/ts/components/glyph/manifestations/canvas-expanded.ts - Export buttonglyph/handlers/canvas.go - HandleExportDOM endpointdocs/demo/index.html - Generated demo fileWhy it works:
Features:
QNTX_DEMO=1Limitations:
Status: Shipped, works great for demos and manual exports.
Problem: We need both approaches
Traditional solution: Maintain two implementations (fragile)
Better solution: Run the same TypeScript code in both environments
React components render both server-side (Node) and client-side (browser):
// This same component runs in both places
function Canvas({ glyphs }) {
return (
<div className="canvas-workspace">
{glyphs.map(g => <Glyph key={g.id} {...g} />)}
</div>
);
}
// Server: const html = renderToString(<Canvas glyphs={data} />)
// Client: ReactDOM.render(<Canvas glyphs={data} />, root)
Canvas-building code runs both in browser and in TypeScript plugin:
// Shared code - works in both environments
export function buildCanvasWorkspace(
canvasId: string,
glyphs: Glyph[],
document: Document // Injected: browser or jsdom
): HTMLElement {
const workspace = document.createElement('div');
workspace.className = 'canvas-workspace';
// ... canvas building logic
return workspace;
}
// Browser usage
const workspace = buildCanvasWorkspace(id, glyphs, window.document);
// Server plugin usage (jsdom)
const dom = new JSDOM('<!DOCTYPE html>');
const workspace = buildCanvasWorkspace(id, glyphs, dom.window.document);
const html = workspace.outerHTML;
Key: Same code, different Document implementation.
See ts-plugin.md for full implementation plan.
Vision:
┌─────────────────────────────────────────────────┐
│ QNTX Server │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ TypeScript Plugin: canvas-renderer │ │
│ │ │ │
│ │ Imports: web/ts/shared/canvas-builder │ │
│ │ Uses: jsdom for server-side DOM │ │
│ │ Exposes: POST /render │ │
│ └──────────────────────────────────────────┘ │
│ │
│ Server calls plugin when posting to Bluesky │
└─────────────────────────────────────────────────┘
Benefits:
Demo/showcase:
User creates impressive canvas
↓
Clicks Export button
↓
HTML saved to docs/demo/index.html
↓
Deploys to GitHub Pages
Manual sharing:
User wants to share canvas with colleague
↓
Exports to HTML
↓
Emails file or hosts on web server
Automated Bluesky posts:
QNTX detects interesting pattern
↓
LLM generates insight
↓
Server calls canvas-renderer plugin
↓
Plugin renders canvas HTML
↓
Pin to IPFS → permanent link
↓
Post to Bluesky with IPFS link
Site builder:
User arranges glyphs as website layout
↓
Exports multiple canvases (pages)
↓
Plugin generates site:
- index.html (home canvas)
- about.html (about canvas)
- Navigation between pages
↓
Deploy to Vercel/Netlify
Documentation generation:
User creates canvas with code + diagrams
↓
Server renders nightly snapshots
↓
Builds versioned documentation site
┌──────────┐
│ Browser │
│ │
│ Canvas │───► DOM Capture ───► HTML + CSS
│ (Live) │
└──────────┘
│
↓ POST /api/canvas/export-dom
┌──────────┐
│ Server │───► Write to disk
└──────────┘
Pros:
Cons:
┌──────────┐
│ Server │
│ │
│ Trigger │───► POST /api/canvas/snapshot
│ (Bluesky)│
└──────────┘
│
↓
┌─────────────────────┐
│ TS Plugin │
│ │
│ 1. Load glyphs │
│ 2. Build canvas │
│ (shared TS code) │
│ 3. Render to HTML │
│ (jsdom) │
└─────────────────────┘
│
↓
HTML ───► Pin to IPFS ───► Return URL
Pros:
Cons:
Both approaches coexist:
Same HTML output from both paths.
Unexpected discovery: The canvas export primitives naturally support static site generation.
What we built:
What it became: A site builder where canvases are pages and glyphs are content.
Example:
Canvas 1 (home.qntx):
- Note glyph: "Welcome to my site"
- Image glyph: hero.png
- Link to Canvas 2
Canvas 2 (about.qntx):
- Note glyph: "About me"
- Code glyph: GitHub embed
Export → Static site:
- home.html (Canvas 1)
- about.html (Canvas 2)
- Navigation preserved
Meta vision: The QNTX website itself is a canvas. View source → it's an exported QNTX canvas.
Attestation layer: Each exported site includes provenance
Problem: Exported HTML was blank because captured body styles overrode layout styles.
Solution: Place critical layout CSS after captured CSS:
<style>
/* Captured from document.styleSheets */
body { font-family: system-ui; margin: 0; }
/* Critical overrides - must come last */
html, body { width: 100%; height: 100%; overflow: hidden; }
body { display: flex !important; flex-direction: column !important; }
</style>
Lesson: CSS cascade order matters. Layout styles need !important to win.
Problem: Initial standalone pan/zoom had buggy touch gestures.
Solution: Extract actual canvas-pan.ts code, strip dependencies (logger, uiState):
// Before: Buggy standalone implementation
// After: Extracted from canvas-pan.ts with minimal changes
// Key: Same gesture detection logic
// - Touch identifier tracking
// - Math.hypot for distance
// - Proper isPanning vs isPinching states
Lesson: Don't reimplement complex logic. Extract and adapt.
Gating: Export features only work when QNTX_DEMO=1:
func (h *CanvasHandler) HandleExportDOM(w http.ResponseWriter, r *http.Request) {
if os.Getenv("QNTX_DEMO") != "1" {
h.writeError(w, errors.New("export only available in demo mode"), http.StatusForbidden)
return
}
// ...
}
Why: Export is for demos/showcases, not production. Prevents accidental use in real deployments.
Goal: Export multiple interconnected canvases as a complete site.
interface SiteExport {
pages: {
[canvasId: string]: {
html: string;
title: string;
path: string; // e.g., "/about"
}
};
navigation: {
from: string; // canvas ID
to: string; // canvas ID
label: string;
}[];
}
Goal: Add metadata to exported HTML for search engines.
<head>
<meta name="description" content="...">
<meta property="og:title" content="...">
<meta property="og:image" content="...">
<link rel="canonical" href="...">
</head>
Source: Glyph attributes or canvas metadata.
Goal: Provide starter canvases for common site types.
Templates:
Each template is a pre-configured canvas with placeholder glyphs.
Goal: Make exported canvases adapt to screen sizes.
Options:
Goal: Host exported sites on custom domains.
Flow:
Export canvas → Deploy to Vercel → Configure DNS → Live site
Integration with deployment platforms (Vercel, Netlify, GitHub Pages).