diff --git a/src/App.vue b/src/App.vue
index 1391c4a..34dd433 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,9 +1,14 @@
@@ -16,6 +21,23 @@ const { source, plan, error, resetToSample } = useMacroplan()
A week-granular, learning-oriented record of what we committed to deliver.
+
+
@@ -30,10 +52,34 @@ const { source, plan, error, resetToSample } = useMacroplan()
- {{ plan.title }}
-
- Nothing to render yet — fix the source on the left.
+
+
{{ plan.title }}
+
+
+
+ Nothing to render yet — fix the source on the left.
+
+
+
+
+
diff --git a/src/composables/usePngExport.ts b/src/composables/usePngExport.ts
new file mode 100644
index 0000000..305b2ac
--- /dev/null
+++ b/src/composables/usePngExport.ts
@@ -0,0 +1,85 @@
+import { ref } from 'vue'
+
+type Toast = { kind: 'ok' | 'err'; text: string } | null
+
+/** Slugified, stable download name derived from the plan title. */
+export function exportFilename(title: string): string {
+ const slug = title
+ .toLowerCase()
+ .trim()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ return `macroplan-${slug || 'plan'}.png`
+}
+
+/**
+ * Client-side PNG export of a rendered Macroplan (C6 / F7). Captures the full
+ * grid — not just the scrolled viewport — by expanding the target to its
+ * content width for the duration of the render, then copies to the clipboard
+ * or downloads. html-to-image is dynamically imported so it stays out of the
+ * initial bundle (like Shiki in the editor).
+ */
+export function usePngExport() {
+ const busy = ref(false)
+ const toast = ref(null)
+ let timer: ReturnType | undefined
+
+ function flash(kind: 'ok' | 'err', text: string) {
+ toast.value = { kind, text }
+ if (timer) clearTimeout(timer)
+ timer = setTimeout(() => (toast.value = null), 3000)
+ }
+
+ async function render(el: HTMLElement): Promise {
+ const { toBlob } = await import('html-to-image')
+ // `exporting` expands the wrapper to max-content and un-clips the grid so the
+ // whole timeline is captured even when it scrolls horizontally on screen.
+ el.classList.add('exporting')
+ // reading layout flushes the class's style changes before we measure
+ const width = el.scrollWidth
+ const height = el.scrollHeight
+ try {
+ const blob = await toBlob(el, { width, height, pixelRatio: 2, cacheBust: true })
+ if (!blob) throw new Error('renderer returned no image')
+ return blob
+ } finally {
+ el.classList.remove('exporting')
+ }
+ }
+
+ async function copyPng(el?: HTMLElement | null) {
+ if (!el || busy.value) return
+ busy.value = true
+ try {
+ // ClipboardItem accepts a Promise, so the async render stays inside
+ // the user-gesture window — Safari rejects a write started after an await.
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': render(el) })])
+ flash('ok', 'PNG copied to clipboard')
+ } catch {
+ flash('err', 'Couldn’t copy — use Download instead')
+ } finally {
+ busy.value = false
+ }
+ }
+
+ async function downloadPng(el?: HTMLElement | null, filename = 'macroplan.png') {
+ if (!el || busy.value) return
+ busy.value = true
+ try {
+ const blob = await render(el)
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = filename
+ a.click()
+ URL.revokeObjectURL(url)
+ flash('ok', 'PNG downloaded')
+ } catch {
+ flash('err', 'Export failed — please retry')
+ } finally {
+ busy.value = false
+ }
+ }
+
+ return { busy, toast, copyPng, downloadPng }
+}