From c4417db3ffbf86e622d8b2b1f61c4ef2b6780c92 Mon Sep 17 00:00:00 2001 From: tph-kds Date: Sat, 15 Aug 2026 03:40:34 +0700 Subject: [PATCH 01/16] feat(scaffold): vendor deck type layer from 02-example --- .../starter-components/deck/assets.ts | 215 + .../deck/geometry-resolver.ts | 439 ++ .../deck/layout-manifest.json | 5050 +++++++++++++++++ .../starter-components/deck/layout.ts | 382 ++ .../deck/scrollbars/scrollbarTypes.ts | 37 + .../deck/slot-validation.ts | 469 ++ .../starter-components/deck/themes.ts | 256 + .../starter-components/deck/types.ts | 319 ++ 8 files changed, 7167 insertions(+) create mode 100644 skills/deckforge/starter-components/deck/assets.ts create mode 100644 skills/deckforge/starter-components/deck/geometry-resolver.ts create mode 100644 skills/deckforge/starter-components/deck/layout-manifest.json create mode 100644 skills/deckforge/starter-components/deck/layout.ts create mode 100644 skills/deckforge/starter-components/deck/scrollbars/scrollbarTypes.ts create mode 100644 skills/deckforge/starter-components/deck/slot-validation.ts create mode 100644 skills/deckforge/starter-components/deck/themes.ts create mode 100644 skills/deckforge/starter-components/deck/types.ts diff --git a/skills/deckforge/starter-components/deck/assets.ts b/skills/deckforge/starter-components/deck/assets.ts new file mode 100644 index 0000000..6cfd4ef --- /dev/null +++ b/skills/deckforge/starter-components/deck/assets.ts @@ -0,0 +1,215 @@ +import type { Block, DeckAsset, DeckProject, DeckSlide, Frame, ImageBlockContent } from './types'; +import { resolveBlockFrame } from './layout'; + +/** + * Media and asset pipeline helpers (plan Workstream E). + * + * Pure, framework-free functions so they can be unit tested and reused by + * editor, presenter, and validators alike. + */ + +export type AssetStatus = 'ready' | 'failed' | 'placeholder'; + +export interface ImageIssue { + severity: 'warning' | 'error'; + code: string; + message: string; +} + +export interface ResolvedImage { + src?: string; + status: AssetStatus; + asset?: DeckAsset; +} + +/** Minimal deck shape the asset helpers depend on, for easy testing. */ +export type AssetDeck = Pick; + +/** Read the image content of a block, tolerating both new and legacy shapes. */ +export function imageContentOf(block: Block): ImageBlockContent { + const raw = block.content; + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + return raw as ImageBlockContent; + } + return { src: typeof raw === 'string' ? raw : undefined }; +} + +/** Look up an asset manifest entry by id. */ +export function resolveAsset(deck: AssetDeck, assetId?: string): DeckAsset | undefined { + if (!assetId) return undefined; + return (deck.assets ?? []).find((asset) => asset.id === assetId); +} + +/** Canonical asset reference for an image block. */ +export interface CanonicalAssetRef { + /** Registry key: manifest asset id, or `inline:` for inline sources. */ + assetId: string; + /** Concrete source to fetch/embed when a real source exists. */ + src?: string; + /** True when `content.assetId` points at a manifest entry that does not exist. */ + orphan?: boolean; +} + +/** + * The single, authoritative way to map an image block to the asset it needs + * embedded in an export. Manifest entries are preferred; inline `src` values + * (legacy editor state) get a deterministic synthetic keyed by block. A + * `content.assetId` that does not exist in the manifest is reported as an + * orphan ONLY when there is no inline source to fall back on — a stale id + * alongside a concrete `src` still exports that src. Returns undefined for + * placeholder blocks with no source at all. + */ +export function canonicalAssetRef(deck: AssetDeck, block: Block): CanonicalAssetRef | undefined { + const content = imageContentOf(block); + if (content.assetId) { + const asset = resolveAsset(deck, content.assetId); + if (asset) return { assetId: asset.id, src: asset.src || undefined }; + const inline = content.src ?? (block as { src?: string }).src; + if (inline) return { assetId: `inline:${block.id}`, src: inline }; + return { assetId: content.assetId, orphan: true }; + } + const inline = content.src ?? (block as { src?: string }).src; + if (inline) return { assetId: `inline:${block.id}`, src: inline }; + return undefined; +} + +/** + * Resolve the concrete source and status for an image block. + * + * - `ready`: a source exists and the asset manifest says it is valid. + * - `placeholder`: no source at all — show a designed theme-integrated placeholder. + * - `failed`: manifest marks it failed, or the block references a missing asset. + */ +export function resolveImage(deck: AssetDeck, block: Block): ResolvedImage { + const content = imageContentOf(block); + const asset = resolveAsset(deck, content.assetId); + + if (asset) { + if (asset.status === 'failed') return { src: undefined, status: 'failed', asset }; + if (asset.src) return { src: asset.src, status: 'ready', asset }; + return { src: undefined, status: 'placeholder', asset }; + } + + if (content.assetId) { + // Referenced manifest entry does not exist. + return { src: undefined, status: 'failed' }; + } + + if (content.src) return { src: content.src, status: 'ready' }; + return { src: undefined, status: 'placeholder' }; +} + +/** Clamp a focal point to the [0,1] range and default it to center. */ +export function clampFocalPoint(focal?: { x?: number; y?: number }): { x: number; y: number } { + if (!focal || typeof focal.x !== 'number' || typeof focal.y !== 'number') { + return { x: 0.5, y: 0.5 }; + } + return { + x: Math.min(1, Math.max(0, focal.x)), + y: Math.min(1, Math.max(0, focal.y)), + }; +} + +/** CSS object-position string for a focal point. */ +export function focalPointToCss(focal?: { x?: number; y?: number }): string { + const point = clampFocalPoint(focal); + return `${(point.x * 100).toFixed(1)}% ${(point.y * 100).toFixed(1)}%`; +} + +/** Aspect ratio (w/h) from an asset's intrinsic dimensions, if known. */ +export function aspectRatioOf(asset?: Pick): number | undefined { if (!asset || !asset.width || !asset.height) return undefined; + return asset.width / asset.height; +} + +/** Frame aspect ratio (w/h). */ +export function frameAspectRatio(frame?: Frame): number | undefined { + if (!frame || !frame.w || !frame.h) return undefined; + return frame.w / frame.h; +} + +/** + * Validate an image block against the asset contract (plan §9.3/§9.4). + * Returns issues that prevent the deck from being marked ready. + */ +export function validateImageBlock(deck: DeckProject, slide: DeckSlide, block: Block): ImageIssue[] { + const issues: ImageIssue[] = []; + const content = imageContentOf(block); + const resolved = resolveImage(deck, block); + + if (!content.decorative && !block.decorative && (!block.alt || block.alt.trim().length === 0)) { + issues.push({ + severity: 'error', + code: 'missing-alt', + message: `Image block ${block.id} has no alt text and is not marked decorative.`, + }); + } + + if (content.assetId && !resolved.asset) { + issues.push({ + severity: 'error', + code: 'unknown-asset', + message: `Image block ${block.id} references missing asset "${content.assetId}".`, + }); + } + + if (resolved.status === 'failed') { + issues.push({ + severity: 'error', + code: 'asset-failed', + message: `Image block ${block.id} references an asset marked failed.`, + }); + } + + if (resolved.asset && !resolved.asset.src) { + issues.push({ + severity: 'error', + code: 'asset-remote-only', + message: `Asset "${content.assetId}" has no local source.`, + }); + } + + if (resolved.asset && !resolved.asset.width && !resolved.asset.height) { + issues.push({ + severity: 'warning', + code: 'unknown-dimensions', + message: `Asset "${content.assetId}" has unknown intrinsic dimensions.`, + }); + } + + if (content.caption && content.caption.trim().length && content.fit !== 'contain') { + issues.push({ + severity: 'warning', + code: 'caption-crop', + message: `Image block ${block.id} has a caption; consider "contain" fit to avoid cropping the subject.`, + }); + } + + const assetRatio = aspectRatioOf(resolved.asset); + const frame = resolveBlockFrame(slide, deck.canvas, block.id); + const frameRatio = frameAspectRatio(frame); + if (assetRatio && frameRatio) { + const mismatch = Math.abs(assetRatio - frameRatio) / Math.max(frameRatio, 1e-6); + if (mismatch > 0.5) { + issues.push({ + severity: 'warning', + code: 'aspect-mismatch', + message: `Image block ${block.id} aspect ratio ${assetRatio.toFixed(2)} vs slot ${frameRatio.toFixed(2)}; a "cover" crop will cut a large area.`, + }); + } + } + + return issues; +} + +/** Aggregate image validation issues across a deck. */ +export function validateDeckAssets(deck: DeckProject): ImageIssue[] { + const issues: ImageIssue[] = []; + for (const slide of deck.slides) { + for (const block of slide.blocks) { + if (block.type === 'image') { + issues.push(...validateImageBlock(deck, slide, block)); + } + } + } + return issues; +} diff --git a/skills/deckforge/starter-components/deck/geometry-resolver.ts b/skills/deckforge/starter-components/deck/geometry-resolver.ts new file mode 100644 index 0000000..ad36d9c --- /dev/null +++ b/skills/deckforge/starter-components/deck/geometry-resolver.ts @@ -0,0 +1,439 @@ +import type { Block, DeckProject, DeckSlide, Frame } from "./types"; +import { + getLayoutContract, + resolveLayout, + resolveSlidePlacements, + type LayoutSlotContract, +} from "./layout"; +import { isUsableFrame, type Rect } from "../export/geometry"; +import { + validateBlockPositioning, + type SlotValidationError, + type BlockValidationResult, +} from "./slot-validation"; + +/** + * deck/geometry-resolver.ts + * + * THE canonical slide-geometry resolution pipeline (Phase 2/5/16). + * + * A SlideDocument stores slot/flow blocks WITHOUT a persisted frame; their + * geometry is a deterministic function of the layout contract + layoutBindings. + * This module resolves EVERY block on a slide to a canonical document-pixel + * frame exactly once, so the editor, presenter, preflight, and the PPTX + * exporter can never disagree about where a block is. + * + * Invariant: visible block => resolvable canonical frame. + * A visible block that cannot be resolved (unbound slot/flow block, or a + * freeform/background block with no frame) is reported in `missingFrames` and + * MUST fail closed — never silently placed at (0,0). + * + * Resolution priority for a single block (single source of truth): + * + * 1. explicit `block.frame` + * 2. positionMode "slot" + valid slot binding → the layout slot frame + * 3. deterministic layout-engine result (auto-bind the block to the best + * slot for its type) — a generated/template block is never allowed to + * exist as positionMode "slot" without a resolvable slot, so the user is + * never forced to hand-bind generated blocks + * 4. versioned legacy migration (a persisted `resolvedFrame` from an older + * hydration run) + * 5. explicit geometry error + * + * `ensureDeckSlotBindings` implements the slot-binding contract at the source: + * it returns a NEW deck whose `layoutBindings` bind every visible slot-positioned + * block to a slot that exists in the active layout contract. + * + * IMPORTANT: resolution never mutates the input deck. Callers that want to + * persist the result may use `hydrateDeckGeometry`, which returns a NEW deck + * with `block.resolvedFrame` attached. + */ + +export interface ResolvedBlockGeometry { + blockId: string; + block: Block; + /** Canonical frame in document pixels (always usable; finite, w>0, h>0). */ + frame: Rect; + slotId?: string; + role?: string; + /** Where the frame came from, for diagnostics: explicit|slot-binding|deterministic-layout|legacy-migration. */ + resolutionSource: BlockFrameSource; +} + +export type BlockFrameSource = + | "explicit" + | "slot-binding" + | "deterministic-layout" + | "legacy-migration"; + +export interface MissingGeometry { + blockId: string; + block: Block; + reason: string; + /** Classification used by preflight diagnostics (Phase 16). */ + state: GeometryDiagnosticState; + /** The slot the block declares, when present. */ + slotId?: string; + layoutId?: string; + /** Structured validation error for developer diagnostics. */ + validationError?: SlotValidationError; +} + +export type GeometryDiagnosticState = + | "MISSING_FRAME" + | "UNKNOWN_SLOT" + | "MISSING_SLOT_ID" + | "NON_FINITE_GEOMETRY" + | "INVALID_SIZE" + | "OUT_OF_BOUNDS"; + +export interface ResolvedSlideScene { + slideId: string; + blocks: ResolvedBlockGeometry[]; + frameByBlockId: Map; + /** Visible blocks with no usable frame. Export/preflight MUST fail closed. */ + missingFrames: MissingGeometry[]; +} + +const NON_SLOT_MODES: ReadonlySet = new Set(["freeform", "background"]); + +/** Is this block on the semantic slot/flow layer (vs freeform/background)? */ +function isSlotMode(block: Block): boolean { + return !NON_SLOT_MODES.has(block.positionMode ?? ""); +} + +function usable(candidate: Frame | undefined): Rect | undefined { + if (!candidate) return undefined; + const rect = { x: candidate.x, y: candidate.y, w: candidate.w, h: candidate.h }; + return isUsableFrame(rect) ? rect : undefined; +} + +function slotAccepts(slot: LayoutSlotContract | undefined, type: string): boolean { + return !slot?.allowedBlocks?.length || slot.allowedBlocks.includes(type); +} + +/** + * Deterministic auto-binding for a slot-positioned block that has no binding. + * Resolution preference: + * + * 1. the block's own `slot`, when it exists in the active layout and accepts + * the block type (and still has room); + * 2. the first slot (in responsive order) that accepts the block type and has + * room; + * 3. any slot with remaining capacity (never drops a block). + * + * `boundCounts` lets multiple unbound blocks share capacity deterministically. + */ +function deterministicSlotId( + block: Block, + slide: DeckSlide, + canvas: DeckProject["canvas"], + boundCounts: Map, +): string | undefined { + const resolved = resolveLayout(slide.layout, canvas); + const slots = resolved.map((entry) => entry.slot); + const responsiveOrder = + slots.length > 0 + ? slots + : ([] as LayoutSlotContract[]); + const hasRoom = (slot: LayoutSlotContract): boolean => { + const count = boundCounts.get(slot.id) ?? 0; + return slot.maxItems == null || count < slot.maxItems; + }; + + if (block.slot) { + const slot = responsiveOrder.find((candidate) => candidate.id === block.slot); + if (slot && slotAccepts(slot, block.type) && hasRoom(slot)) return slot.id; + } + + const preferred = responsiveOrder.find( + (slot) => slotAccepts(slot, block.type) && hasRoom(slot), + ); + if (preferred) return preferred.id; + + return responsiveOrder.find(hasRoom)?.id; +} + +export interface BlockFrameResult { + frame: Rect; + slotId?: string; + role?: string; + source: BlockFrameSource; +} + +/** + * Resolve the canonical frame for a single block (single source of truth). + * Resolution priority: + * + * 1. explicit `block.frame` + * 2. a valid slot binding → the layout slot frame + * 3. a deterministic auto-binding → the layout slot frame + * 4. a persisted `resolvedFrame` (legacy migration) + * 5. nothing (caller reports the geometry error) + */ +export function resolveBlockFrame( + block: Block, + slide: DeckSlide, + canvas: DeckProject["canvas"], + placement: { slotId: string; role: string; frame: Frame } | undefined, + boundCounts?: Map, +): BlockFrameResult | undefined { + const mode = block.positionMode ?? ""; + + if (mode === "freeform" || mode === "background") { + const explicit = usable(block.frame); + if (explicit) return { frame: explicit, source: "explicit" }; + const legacy = usable(block.resolvedFrame); + if (legacy) return { frame: legacy, source: "legacy-migration" }; + return undefined; + } + + // 1. explicit frame wins for slot/flow blocks too. + const explicit = usable(block.frame); + if (explicit) return { frame: explicit, source: "explicit" }; + + // 2. valid slot binding. + if (placement) { + const slotFrame = usable(placement.frame); + if (slotFrame) { + return { + frame: slotFrame, + slotId: placement.slotId, + role: placement.role, + source: "slot-binding", + }; + } + } + + // 3. deterministic auto-binding (slot-positioned blocks never go unbound). + const counts = boundCounts ?? new Map(); + const slotId = deterministicSlotId(block, slide, canvas, counts); + if (slotId) { + const entry = resolveLayout(slide.layout, canvas).find((entry) => entry.slot.id === slotId); + const slotFrame = usable(entry?.frame); + if (slotFrame) { + counts.set(slotId, (counts.get(slotId) ?? 0) + 1); + return { + frame: slotFrame, + slotId, + role: entry!.slot.role, + source: "deterministic-layout", + }; + } + } + + // 4. legacy migration. + const legacy = usable(block.resolvedFrame); + if (legacy) return { frame: legacy, source: "legacy-migration" }; + + // 5. explicit geometry error (caller reports). + return undefined; +} + +function geometryStateFor( + block: Block, + slide: DeckSlide, +): GeometryDiagnosticState { + if (block.frame) { + const errors = [ + ...(!Number.isFinite(block.frame.x) ? ["x"] : []), + ...(!Number.isFinite(block.frame.y) ? ["y"] : []), + ...(!Number.isFinite(block.frame.w) ? ["w"] : []), + ...(!Number.isFinite(block.frame.h) ? ["h"] : []), + ]; + if (errors.length) return "NON_FINITE_GEOMETRY"; + if (block.frame.w <= 0 || block.frame.h <= 0) return "INVALID_SIZE"; + return "OUT_OF_BOUNDS"; + } + if (block.positionMode === "slot") { + if (!block.slot) return "MISSING_SLOT_ID"; + const layout = getLayoutContract(slide.layout); + const hasSlot = layout?.composition.slots.some((slot) => slot.id === block.slot) ?? false; + if (!hasSlot) return "UNKNOWN_SLOT"; + } + return "MISSING_FRAME"; +} + +/** + * Resolve the canonical frame for a single block given its slot placement + * (when bound) and the canvas. Returns undefined when no usable frame exists. + */ +export function resolveBlockGeometry( + block: Block, + placement: { slotId: string; role: string; frame: Frame } | undefined, + slide?: DeckSlide, + canvas?: DeckProject["canvas"], +): ResolvedBlockGeometry | undefined { + const resolved = resolveBlockFrame( + block, + slide ?? { + id: "standalone", + title: "", + layout: "two-column", + blocks: [block], + } as DeckSlide, + canvas ?? { aspectRatio: "16:9", width: 1600, height: 900, safeMargin: 64 } as DeckProject["canvas"], + placement, + ); + if (!resolved) return undefined; + return { + blockId: block.id, + block, + frame: resolved.frame, + slotId: resolved.slotId, + role: resolved.role, + resolutionSource: resolved.source, + }; +} + +/** + * Ensure the slot-binding contract: every visible slot-positioned block is + * bound to a slot that exists in the active layout. Returns a NEW slide; the + * input is never mutated. + */ +export function ensureSlideSlotBindings(slide: DeckSlide, canvas: DeckProject["canvas"]): DeckSlide { + const contractSlots = resolveLayout(slide.layout, canvas).map((entry) => entry.slot.id); + const slotSet = new Set(contractSlots); + const bindings = new Map>(); + for (const binding of slide.layoutBindings ?? []) { + if (!slotSet.has(binding.slot)) continue; + bindings.set(binding.slot, new Set(binding.blockIds)); + } + + const blockById = new Map(slide.blocks.map((block) => [block.id, block])); + const boundCounts = new Map(); + for (const [slotId, ids] of bindings) { + boundCounts.set(slotId, ids.size); + } + + for (const block of slide.blocks) { + if (block.hidden) continue; + if (!isSlotMode(block)) continue; + const alreadyBound = [...bindings.values()].some((ids) => ids.has(block.id)); + if (alreadyBound) continue; + const slotId = deterministicSlotId(block, slide, canvas, boundCounts); + if (!slotId) continue; + if (!bindings.has(slotId)) bindings.set(slotId, new Set()); + bindings.get(slotId)!.add(block.id); + boundCounts.set(slotId, (boundCounts.get(slotId) ?? 0) + 1); + } + + const blockSeen = new Set(); + const ordered = contractSlots + .filter((slotId) => bindings.has(slotId)) + .map((slotId) => { + const ids = [...bindings.get(slotId)!].filter((id) => blockById.has(id)); + ids.forEach((id) => blockSeen.add(id)); + return { slot: slotId, blockIds: ids }; + }); + + return { ...slide, layoutBindings: ordered }; +} + +/** + * Ensure the slot-binding contract across the whole deck. Returns a NEW deck + * whose `layoutBindings` bind every visible slot-positioned block to a valid + * slot, so generated/template decks never ship an unbound slot block. + */ +export function ensureDeckSlotBindings(deck: DeckProject): DeckProject { + const canvas = deck.canvas ?? { aspectRatio: "16:9", width: 1600, height: 900, safeMargin: 64 }; + return { + ...deck, + slides: deck.slides.map((slide) => ensureSlideSlotBindings(slide, canvas)), + }; +} + +/** Resolve every block on a slide to its canonical frame. */ +export function resolveSlideGeometry( + slide: DeckSlide, + canvas: DeckProject["canvas"], +): ResolvedSlideScene { + const placements = resolveSlidePlacements(slide, canvas); + const placementByBlock = new Map< + string, + { slotId: string; role: string; frame: Frame } + >(); + for (const placement of placements) { + placementByBlock.set(placement.blockId, { + slotId: placement.slotId, + role: placement.slot.role, + frame: placement.frame, + }); + } + + const blocks: ResolvedBlockGeometry[] = []; + const frameByBlockId = new Map(); + const missingFrames: MissingGeometry[] = []; + const boundCounts = new Map(); + for (const placement of placements) { + boundCounts.set(placement.slotId, (boundCounts.get(placement.slotId) ?? 0) + 1); + } + + for (const block of slide.blocks) { + if (block.hidden) continue; + const placement = placementByBlock.get(block.id); + const resolved = resolveBlockFrame(block, slide, canvas, placement, boundCounts); + if (resolved) { + blocks.push({ + blockId: block.id, + block, + frame: resolved.frame, + slotId: resolved.slotId, + role: resolved.role, + resolutionSource: resolved.source, + }); + frameByBlockId.set(block.id, resolved.frame); + } else { + // Get structured validation error for developer diagnostics + const validationResult = validateBlockPositioning(block, slide, canvas); + const validationError = validationResult.errors.length > 0 ? validationResult.errors[0] : undefined; + + missingFrames.push({ + blockId: block.id, + block, + slotId: block.slot, + layoutId: slide.layout, + state: geometryStateFor(block, slide), + reason: `${block.type} block "${block.id}" (positionMode "${block.positionMode ?? "slot"}") has no resolvable frame`, + validationError, + }); + } + } + + return { slideId: slide.id, blocks, frameByBlockId, missingFrames }; +} + +/** Resolve all slides in a deck. */ +export function resolveDeckScenes(deck: DeckProject): Map { + const scenes = new Map(); + for (const slide of deck.slides) { + scenes.set(slide.id, resolveSlideGeometry(slide, deck.canvas)); + } + return scenes; +} + +/** + * Return a NEW deck with `block.resolvedFrame` attached to every resolvable + * block. Safe for document load / migration / post-mutation hydration: the + * original deck is never mutated, so editor history and export both stay + * deterministic and idempotent. + */ +export function hydrateDeckGeometry(deck: DeckProject): DeckProject { + const scenes = resolveDeckScenes(deck); + const hydrated: DeckProject = { + ...deck, + slides: deck.slides.map((slide) => { + const scene = scenes.get(slide.id); + if (!scene) return slide; + return { + ...slide, + blocks: slide.blocks.map((block) => { + const frame = scene.frameByBlockId.get(block.id); + if (!frame) return block; + return { ...block, resolvedFrame: { ...frame } }; + }), + }; + }), + }; + return hydrated; +} diff --git a/skills/deckforge/starter-components/deck/layout-manifest.json b/skills/deckforge/starter-components/deck/layout-manifest.json new file mode 100644 index 0000000..fc743fb --- /dev/null +++ b/skills/deckforge/starter-components/deck/layout-manifest.json @@ -0,0 +1,5050 @@ +[ + { + "id": "title-hero", + "name": "Title Hero", + "category": "Opening", + "purpose": "One decisive title, short subtitle, one visual anchor", + "density": "low", + "recommendedBlocks": [ + "heading", + "text", + "image" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 7, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 75, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 7, + "rowSpan": 3 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 78, + "maxLines": 3 + } + }, + { + "id": "subtitle", + "role": "support", + "grid": { + "column": 1, + "row": 5, + "columnSpan": 7, + "rowSpan": 2 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 2, + "priority": "normal", + "contentBudget": { + "maxCharacters": 220, + "maxLines": 4 + } + }, + { + "id": "meta", + "role": "footer", + "grid": { + "column": 1, + "row": 7, + "columnSpan": 7, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text", + "caption" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 120, + "maxLines": 2 + } + }, + { + "id": "visual", + "role": "visual", + "grid": { + "column": 9, + "row": 2, + "columnSpan": 4, + "rowSpan": 5 + }, + "allowedBlocks": [ + "image", + "video", + "gallery", + "browser-demo", + "device", + "embed", + "diagram", + "chart", + "map", + "code", + "terminal-demo", + "icon" + ], + "required": false, + "maxItems": 1, + "priority": "secondary" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "subtitle", + "visual", + "meta" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.34, + "maxOccupiedRatio": 0.74 + }, + "freeformAllowed": false, + "notes": "Title and visual occupy disjoint regions. Never allow the title to extend into columns 9–12." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "section-divider", + "name": "Section Divider", + "category": "Opening", + "purpose": "Section title with chapter number and quiet context", + "density": "low", + "recommendedBlocks": [ + "heading", + "text" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "chapter", + "role": "context", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 3, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text", + "metric" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 20, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 10, + "rowSpan": 2 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 80, + "maxLines": 2 + } + }, + { + "id": "context", + "role": "support", + "grid": { + "column": 1, + "row": 5, + "columnSpan": 8, + "rowSpan": 2 + }, + "allowedBlocks": [ + "text", + "quote" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 180, + "maxLines": 4 + } + }, + { + "id": "accent", + "role": "visual", + "grid": { + "column": 10, + "row": 2, + "columnSpan": 3, + "rowSpan": 4 + }, + "allowedBlocks": [ + "image", + "video", + "gallery", + "browser-demo", + "device", + "embed", + "diagram", + "chart", + "map", + "code", + "terminal-demo", + "icon" + ], + "required": false, + "maxItems": 1, + "priority": "normal" + } + ], + "responsiveOrder": [ + "chapter", + "title", + "context", + "accent" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.25, + "maxOccupiedRatio": 0.64 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "statement", + "name": "Statement", + "category": "Narrative", + "purpose": "Single argument or conclusion with supporting phrase", + "density": "low", + "recommendedBlocks": [ + "heading", + "text" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "statement", + "role": "title", + "grid": { + "column": 2, + "row": 2, + "columnSpan": 10, + "rowSpan": 4 + }, + "allowedBlocks": [ + "heading", + "quote" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 120, + "maxLines": 4 + } + }, + { + "id": "support", + "role": "support", + "grid": { + "column": 3, + "row": 6, + "columnSpan": 8, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text", + "citation" + ], + "required": false, + "maxItems": 2, + "priority": "normal", + "contentBudget": { + "maxCharacters": 180, + "maxLines": 2 + } + } + ], + "responsiveOrder": [ + "statement", + "support" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.2, + "maxOccupiedRatio": 0.58 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "centered-quote", + "name": "Centered Quote", + "category": "Narrative", + "purpose": "Short quote with source and restrained treatment", + "density": "low", + "recommendedBlocks": [ + "quote", + "citation" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "quote", + "role": "title", + "grid": { + "column": 2, + "row": 2, + "columnSpan": 10, + "rowSpan": 4 + }, + "allowedBlocks": [ + "quote", + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 220, + "maxLines": 6 + } + }, + { + "id": "source", + "role": "source", + "grid": { + "column": 4, + "row": 6, + "columnSpan": 6, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text", + "citation", + "caption" + ], + "required": true, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 120, + "maxLines": 2 + } + } + ], + "responsiveOrder": [ + "quote", + "source" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.22, + "maxOccupiedRatio": 0.6 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "big-number", + "name": "Big Number", + "category": "Data", + "purpose": "One dominant metric with meaning and comparison", + "density": "low", + "recommendedBlocks": [ + "metric", + "text" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "context", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "metric", + "role": "primary", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 7, + "rowSpan": 4 + }, + "allowedBlocks": [ + "metric" + ], + "required": true, + "maxItems": 1, + "priority": "primary" + }, + { + "id": "meaning", + "role": "support", + "grid": { + "column": 8, + "row": 3, + "columnSpan": 5, + "rowSpan": 3 + }, + "allowedBlocks": [ + "heading", + "text", + "callout", + "chart" + ], + "required": false, + "maxItems": 3, + "priority": "normal", + "contentBudget": { + "maxCharacters": 220, + "maxLines": 6 + } + }, + { + "id": "footer", + "role": "footer", + "grid": { + "column": 1, + "row": 8, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text", + "caption", + "citation" + ], + "required": false, + "maxItems": 3, + "priority": "normal", + "contentBudget": { + "maxCharacters": 180, + "maxLines": 2 + } + } + ], + "responsiveOrder": [ + "context", + "metric", + "meaning", + "footer" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.35, + "maxOccupiedRatio": 0.72 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "metric-grid", + "name": "Metric Grid", + "category": "Data", + "purpose": "Three to six metrics with consistent units and hierarchy", + "density": "medium", + "recommendedBlocks": [ + "metric", + "card-grid" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "metrics", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 12, + "rowSpan": 4 + }, + "allowedBlocks": [ + "metric" + ], + "required": true, + "maxItems": 6, + "priority": "primary" + }, + { + "id": "takeaway", + "role": "support", + "grid": { + "column": 1, + "row": 7, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text", + "callout" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 160, + "maxLines": 2 + } + } + ], + "responsiveOrder": [ + "kicker", + "title", + "metrics", + "takeaway" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.44, + "maxOccupiedRatio": 0.8 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "two-column", + "name": "Two Column", + "category": "General", + "purpose": "Balanced text and visual or two related ideas", + "density": "medium", + "recommendedBlocks": [ + "heading", + "text", + "image" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "left", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 6, + "rowSpan": 5 + }, + "allowedBlocks": [ + "heading", + "text", + "bullets", + "callout", + "caption", + "citation", + "quote", + "image", + "video", + "gallery", + "browser-demo", + "device", + "embed", + "diagram", + "chart", + "map", + "code", + "terminal-demo", + "metric", + "table", + "comparison", + "timeline", + "process", + "icon", + "logo-wall", + "people-grid", + "audio" + ], + "required": true, + "maxItems": 5, + "priority": "primary" + }, + { + "id": "right", + "role": "secondary", + "grid": { + "column": 7, + "row": 3, + "columnSpan": 6, + "rowSpan": 5 + }, + "allowedBlocks": [ + "heading", + "text", + "bullets", + "callout", + "caption", + "citation", + "quote", + "image", + "video", + "gallery", + "browser-demo", + "device", + "embed", + "diagram", + "chart", + "map", + "code", + "terminal-demo", + "metric", + "table", + "comparison", + "timeline", + "process", + "icon", + "logo-wall", + "people-grid", + "audio" + ], + "required": true, + "maxItems": 4, + "priority": "secondary" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "left", + "right" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.46, + "maxOccupiedRatio": 0.84 + }, + "freeformAllowed": false, + "notes": "Use independent flow containers. Never let left/right blocks cross the column boundary." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "split-visual", + "name": "Split Visual", + "category": "General", + "purpose": "Large visual paired with concise interpretation", + "density": "medium", + "recommendedBlocks": [ + "image", + "text", + "caption" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "visual", + "role": "visual", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 7, + "rowSpan": 5 + }, + "allowedBlocks": [ + "image", + "video", + "gallery", + "browser-demo", + "device", + "embed", + "diagram", + "chart", + "map", + "code", + "terminal-demo" + ], + "required": true, + "maxItems": 1, + "priority": "primary" + }, + { + "id": "interpretation", + "role": "support", + "grid": { + "column": 8, + "row": 3, + "columnSpan": 5, + "rowSpan": 5 + }, + "allowedBlocks": [ + "heading", + "text", + "bullets", + "callout", + "caption", + "citation", + "quote", + "metric" + ], + "required": true, + "maxItems": 5, + "priority": "secondary" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "visual", + "interpretation" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.5, + "maxOccupiedRatio": 0.86 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "three-column", + "name": "Three Column", + "category": "General", + "purpose": "Three parallel concepts with equal weight", + "density": "medium", + "recommendedBlocks": [ + "card-grid", + "icon", + "text" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "column-1", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 4, + "rowSpan": 5 + }, + "allowedBlocks": [ + "heading", + "text", + "bullets", + "callout", + "caption", + "citation", + "quote", + "image", + "video", + "gallery", + "browser-demo", + "device", + "embed", + "diagram", + "chart", + "map", + "code", + "terminal-demo", + "metric", + "table", + "comparison", + "timeline", + "process", + "icon", + "logo-wall", + "people-grid", + "audio" + ], + "required": true, + "maxItems": 4, + "priority": "normal" + }, + { + "id": "column-2", + "role": "primary", + "grid": { + "column": 5, + "row": 3, + "columnSpan": 4, + "rowSpan": 5 + }, + "allowedBlocks": [ + "heading", + "text", + "bullets", + "callout", + "caption", + "citation", + "quote", + "image", + "video", + "gallery", + "browser-demo", + "device", + "embed", + "diagram", + "chart", + "map", + "code", + "terminal-demo", + "metric", + "table", + "comparison", + "timeline", + "process", + "icon", + "logo-wall", + "people-grid", + "audio" + ], + "required": true, + "maxItems": 4, + "priority": "normal" + }, + { + "id": "column-3", + "role": "primary", + "grid": { + "column": 9, + "row": 3, + "columnSpan": 4, + "rowSpan": 5 + }, + "allowedBlocks": [ + "heading", + "text", + "bullets", + "callout", + "caption", + "citation", + "quote", + "image", + "video", + "gallery", + "browser-demo", + "device", + "embed", + "diagram", + "chart", + "map", + "code", + "terminal-demo", + "metric", + "table", + "comparison", + "timeline", + "process", + "icon", + "logo-wall", + "people-grid", + "audio" + ], + "required": true, + "maxItems": 4, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "column-1", + "column-2", + "column-3" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.48, + "maxOccupiedRatio": 0.86 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "card-grid", + "name": "Card Grid", + "category": "General", + "purpose": "Modular points where cards are semantically justified", + "density": "medium", + "recommendedBlocks": [ + "card-grid" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "cards", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 12, + "rowSpan": 5 + }, + "allowedBlocks": [ + "callout", + "metric", + "image", + "text", + "people-grid", + "logo-wall" + ], + "required": true, + "maxItems": 8, + "priority": "primary" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "cards" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.48, + "maxOccupiedRatio": 0.88 + }, + "freeformAllowed": false, + "notes": "Cards must be semantically justified and use a consistent internal hierarchy." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "timeline-horizontal", + "name": "Timeline Horizontal", + "category": "Process", + "purpose": "Time-based milestones across a horizontal axis", + "density": "medium", + "recommendedBlocks": [ + "timeline" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "timeline", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 12, + "rowSpan": 4 + }, + "allowedBlocks": [ + "timeline", + "process", + "diagram" + ], + "required": true, + "maxItems": 1, + "priority": "primary" + }, + { + "id": "takeaway", + "role": "support", + "grid": { + "column": 1, + "row": 7, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text", + "callout" + ], + "required": false, + "maxItems": 2, + "priority": "normal", + "contentBudget": { + "maxCharacters": 180, + "maxLines": 2 + } + } + ], + "responsiveOrder": [ + "kicker", + "title", + "timeline", + "takeaway" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.42, + "maxOccupiedRatio": 0.82 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "timeline-vertical", + "name": "Timeline Vertical", + "category": "Process", + "purpose": "Chronological narrative with more explanatory text", + "density": "medium", + "recommendedBlocks": [ + "timeline" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "timeline", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 7, + "rowSpan": 5 + }, + "allowedBlocks": [ + "timeline", + "process" + ], + "required": true, + "maxItems": 1, + "priority": "primary" + }, + { + "id": "details", + "role": "support", + "grid": { + "column": 8, + "row": 3, + "columnSpan": 5, + "rowSpan": 5 + }, + "allowedBlocks": [ + "heading", + "text", + "bullets", + "callout", + "caption", + "citation", + "quote", + "metric" + ], + "required": false, + "maxItems": 5, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "timeline", + "details" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.48, + "maxOccupiedRatio": 0.84 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "process-steps", + "name": "Process Steps", + "category": "Process", + "purpose": "Three to seven ordered actions with clear progression", + "density": "medium", + "recommendedBlocks": [ + "process" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "steps", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 12, + "rowSpan": 4 + }, + "allowedBlocks": [ + "process", + "timeline", + "diagram" + ], + "required": true, + "maxItems": 1, + "priority": "primary" + }, + { + "id": "outcome", + "role": "support", + "grid": { + "column": 2, + "row": 7, + "columnSpan": 10, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text", + "callout" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 180, + "maxLines": 2 + } + } + ], + "responsiveOrder": [ + "kicker", + "title", + "steps", + "outcome" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.42, + "maxOccupiedRatio": 0.82 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "funnel", + "name": "Funnel", + "category": "Process", + "purpose": "Narrowing stages with volume or qualification changes", + "density": "medium", + "recommendedBlocks": [ + "diagram" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "funnel", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 7, + "rowSpan": 5 + }, + "allowedBlocks": [ + "diagram", + "chart", + "process" + ], + "required": true, + "maxItems": 1, + "priority": "normal" + }, + { + "id": "metrics", + "role": "support", + "grid": { + "column": 8, + "row": 3, + "columnSpan": 5, + "rowSpan": 5 + }, + "allowedBlocks": [ + "metric", + "text", + "callout" + ], + "required": false, + "maxItems": 5, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "funnel", + "metrics" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.45, + "maxOccupiedRatio": 0.82 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "pyramid", + "name": "Pyramid", + "category": "Strategy", + "purpose": "Layered priorities, maturity, or hierarchy", + "density": "medium", + "recommendedBlocks": [ + "diagram" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "pyramid", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 7, + "rowSpan": 5 + }, + "allowedBlocks": [ + "diagram", + "chart" + ], + "required": true, + "maxItems": 1, + "priority": "normal" + }, + { + "id": "explanation", + "role": "support", + "grid": { + "column": 8, + "row": 3, + "columnSpan": 5, + "rowSpan": 5 + }, + "allowedBlocks": [ + "heading", + "text", + "bullets", + "callout", + "caption", + "citation", + "quote", + "metric" + ], + "required": false, + "maxItems": 5, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "pyramid", + "explanation" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.45, + "maxOccupiedRatio": 0.82 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "matrix-2x2", + "name": "Matrix 2X2", + "category": "Strategy", + "purpose": "Four quadrants with meaningful axes and labels", + "density": "medium", + "recommendedBlocks": [ + "diagram" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "matrix", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 8, + "rowSpan": 5 + }, + "allowedBlocks": [ + "diagram", + "chart", + "comparison" + ], + "required": true, + "maxItems": 1, + "priority": "normal" + }, + { + "id": "insight", + "role": "support", + "grid": { + "column": 9, + "row": 3, + "columnSpan": 4, + "rowSpan": 5 + }, + "allowedBlocks": [ + "text", + "callout", + "metric" + ], + "required": false, + "maxItems": 4, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "matrix", + "insight" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.48, + "maxOccupiedRatio": 0.84 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "comparison", + "name": "Comparison", + "category": "Decision", + "purpose": "Side-by-side alternatives with explicit criteria", + "density": "high", + "recommendedBlocks": [ + "comparison", + "table" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "option-a", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 5, + "rowSpan": 4 + }, + "allowedBlocks": [ + "heading", + "text", + "bullets", + "callout", + "caption", + "citation", + "quote", + "image", + "video", + "gallery", + "browser-demo", + "device", + "embed", + "diagram", + "chart", + "map", + "code", + "terminal-demo", + "metric", + "table", + "comparison", + "timeline", + "process", + "icon", + "logo-wall", + "people-grid", + "audio" + ], + "required": true, + "maxItems": 5, + "priority": "normal" + }, + { + "id": "criteria", + "role": "context", + "grid": { + "column": 6, + "row": 3, + "columnSpan": 2, + "rowSpan": 4 + }, + "allowedBlocks": [ + "text", + "table", + "comparison" + ], + "required": false, + "maxItems": 6, + "priority": "normal" + }, + { + "id": "option-b", + "role": "primary", + "grid": { + "column": 8, + "row": 3, + "columnSpan": 5, + "rowSpan": 4 + }, + "allowedBlocks": [ + "heading", + "text", + "bullets", + "callout", + "caption", + "citation", + "quote", + "image", + "video", + "gallery", + "browser-demo", + "device", + "embed", + "diagram", + "chart", + "map", + "code", + "terminal-demo", + "metric", + "table", + "comparison", + "timeline", + "process", + "icon", + "logo-wall", + "people-grid", + "audio" + ], + "required": true, + "maxItems": 5, + "priority": "normal" + }, + { + "id": "decision", + "role": "support", + "grid": { + "column": 1, + "row": 7, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "callout", + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 180, + "maxLines": 2 + } + } + ], + "responsiveOrder": [ + "kicker", + "title", + "option-a", + "criteria", + "option-b", + "decision" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.5, + "maxOccupiedRatio": 0.88 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "before-after", + "name": "Before After", + "category": "Decision", + "purpose": "Current state versus target state", + "density": "medium", + "recommendedBlocks": [ + "comparison", + "image" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "before", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 5, + "rowSpan": 4 + }, + "allowedBlocks": [ + "heading", + "text", + "bullets", + "callout", + "caption", + "citation", + "quote", + "image", + "video", + "gallery", + "browser-demo", + "device", + "embed", + "diagram", + "chart", + "map", + "code", + "terminal-demo", + "metric", + "table", + "comparison", + "timeline", + "process", + "icon", + "logo-wall", + "people-grid", + "audio" + ], + "required": true, + "maxItems": 5, + "priority": "normal" + }, + { + "id": "transition", + "role": "context", + "grid": { + "column": 6, + "row": 4, + "columnSpan": 2, + "rowSpan": 2 + }, + "allowedBlocks": [ + "icon", + "text" + ], + "required": false, + "maxItems": 2, + "priority": "normal" + }, + { + "id": "after", + "role": "primary", + "grid": { + "column": 8, + "row": 3, + "columnSpan": 5, + "rowSpan": 4 + }, + "allowedBlocks": [ + "heading", + "text", + "bullets", + "callout", + "caption", + "citation", + "quote", + "image", + "video", + "gallery", + "browser-demo", + "device", + "embed", + "diagram", + "chart", + "map", + "code", + "terminal-demo", + "metric", + "table", + "comparison", + "timeline", + "process", + "icon", + "logo-wall", + "people-grid", + "audio" + ], + "required": true, + "maxItems": 5, + "priority": "normal" + }, + { + "id": "impact", + "role": "support", + "grid": { + "column": 1, + "row": 7, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "metric", + "callout", + "text" + ], + "required": false, + "maxItems": 3, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "before", + "transition", + "after", + "impact" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.48, + "maxOccupiedRatio": 0.86 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "architecture", + "name": "Architecture", + "category": "Technical", + "purpose": "System components, boundaries, and data flows", + "density": "high", + "recommendedBlocks": [ + "diagram", + "code" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "diagram", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 9, + "rowSpan": 5 + }, + "allowedBlocks": [ + "diagram" + ], + "required": true, + "maxItems": 1, + "priority": "primary" + }, + { + "id": "legend", + "role": "support", + "grid": { + "column": 10, + "row": 3, + "columnSpan": 3, + "rowSpan": 3 + }, + "allowedBlocks": [ + "text", + "caption", + "callout" + ], + "required": false, + "maxItems": 5, + "priority": "normal" + }, + { + "id": "takeaway", + "role": "support", + "grid": { + "column": 10, + "row": 6, + "columnSpan": 3, + "rowSpan": 2 + }, + "allowedBlocks": [ + "text", + "callout" + ], + "required": false, + "maxItems": 2, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "diagram", + "legend", + "takeaway" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.5, + "maxOccupiedRatio": 0.88 + }, + "freeformAllowed": false, + "notes": "All diagram nodes and edge labels must remain inside the diagram slot." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "flowchart", + "name": "Flowchart", + "category": "Technical", + "purpose": "Decision or operational flow with directional logic", + "density": "high", + "recommendedBlocks": [ + "diagram" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "flow", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 9, + "rowSpan": 5 + }, + "allowedBlocks": [ + "diagram", + "process" + ], + "required": true, + "maxItems": 1, + "priority": "normal" + }, + { + "id": "notes", + "role": "support", + "grid": { + "column": 10, + "row": 3, + "columnSpan": 3, + "rowSpan": 5 + }, + "allowedBlocks": [ + "text", + "callout", + "caption" + ], + "required": false, + "maxItems": 5, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "flow", + "notes" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.48, + "maxOccupiedRatio": 0.86 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "radial", + "name": "Radial", + "category": "Technical", + "purpose": "Hub-and-spoke ecosystem or capability map", + "density": "medium", + "recommendedBlocks": [ + "diagram" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "map", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 8, + "rowSpan": 5 + }, + "allowedBlocks": [ + "diagram", + "chart" + ], + "required": true, + "maxItems": 1, + "priority": "normal" + }, + { + "id": "legend", + "role": "support", + "grid": { + "column": 9, + "row": 3, + "columnSpan": 4, + "rowSpan": 5 + }, + "allowedBlocks": [ + "text", + "callout", + "metric" + ], + "required": false, + "maxItems": 6, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "map", + "legend" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.46, + "maxOccupiedRatio": 0.84 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "map", + "name": "Map", + "category": "Geographic", + "purpose": "Locations or regional metrics with legend", + "density": "high", + "recommendedBlocks": [ + "map" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "map", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 8, + "rowSpan": 5 + }, + "allowedBlocks": [ + "map", + "image", + "chart" + ], + "required": true, + "maxItems": 1, + "priority": "normal" + }, + { + "id": "legend", + "role": "support", + "grid": { + "column": 9, + "row": 3, + "columnSpan": 4, + "rowSpan": 3 + }, + "allowedBlocks": [ + "text", + "caption", + "metric" + ], + "required": false, + "maxItems": 6, + "priority": "normal" + }, + { + "id": "takeaway", + "role": "support", + "grid": { + "column": 9, + "row": 6, + "columnSpan": 4, + "rowSpan": 2 + }, + "allowedBlocks": [ + "callout", + "text" + ], + "required": false, + "maxItems": 2, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "map", + "legend", + "takeaway" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.5, + "maxOccupiedRatio": 0.86 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "dashboard", + "name": "Dashboard", + "category": "Data", + "purpose": "Multiple coordinated views with a clear focal KPI", + "density": "high", + "recommendedBlocks": [ + "metric", + "chart", + "table" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "hero-metric", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 3, + "rowSpan": 2 + }, + "allowedBlocks": [ + "metric" + ], + "required": true, + "maxItems": 1, + "priority": "normal" + }, + { + "id": "primary-chart", + "role": "primary", + "grid": { + "column": 4, + "row": 3, + "columnSpan": 6, + "rowSpan": 3 + }, + "allowedBlocks": [ + "chart" + ], + "required": true, + "maxItems": 1, + "priority": "normal" + }, + { + "id": "secondary-metrics", + "role": "secondary", + "grid": { + "column": 10, + "row": 3, + "columnSpan": 3, + "rowSpan": 3 + }, + "allowedBlocks": [ + "metric" + ], + "required": false, + "maxItems": 4, + "priority": "normal" + }, + { + "id": "supporting-view", + "role": "secondary", + "grid": { + "column": 1, + "row": 6, + "columnSpan": 9, + "rowSpan": 2 + }, + "allowedBlocks": [ + "chart", + "table", + "text" + ], + "required": false, + "maxItems": 2, + "priority": "normal" + }, + { + "id": "takeaway", + "role": "support", + "grid": { + "column": 10, + "row": 6, + "columnSpan": 3, + "rowSpan": 2 + }, + "allowedBlocks": [ + "callout", + "text" + ], + "required": false, + "maxItems": 2, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "hero-metric", + "primary-chart", + "secondary-metrics", + "supporting-view", + "takeaway" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.58, + "maxOccupiedRatio": 0.9 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "chart-focus", + "name": "Chart Focus", + "category": "Data", + "purpose": "One chart plus headline insight and annotation", + "density": "medium", + "recommendedBlocks": [ + "chart", + "caption" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "chart", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 9, + "rowSpan": 5 + }, + "allowedBlocks": [ + "chart" + ], + "required": true, + "maxItems": 1, + "priority": "normal" + }, + { + "id": "annotation", + "role": "support", + "grid": { + "column": 10, + "row": 3, + "columnSpan": 3, + "rowSpan": 3 + }, + "allowedBlocks": [ + "text", + "callout", + "metric" + ], + "required": false, + "maxItems": 3, + "priority": "normal" + }, + { + "id": "source", + "role": "source", + "grid": { + "column": 10, + "row": 6, + "columnSpan": 3, + "rowSpan": 2 + }, + "allowedBlocks": [ + "citation", + "caption", + "text" + ], + "required": false, + "maxItems": 3, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "chart", + "annotation", + "source" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.52, + "maxOccupiedRatio": 0.87 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "table-focus", + "name": "Table Focus", + "category": "Data", + "purpose": "Compact table with highlighted rows or variance", + "density": "high", + "recommendedBlocks": [ + "table" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "table", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 9, + "rowSpan": 5 + }, + "allowedBlocks": [ + "table", + "comparison" + ], + "required": true, + "maxItems": 1, + "priority": "normal" + }, + { + "id": "insight", + "role": "support", + "grid": { + "column": 10, + "row": 3, + "columnSpan": 3, + "rowSpan": 3 + }, + "allowedBlocks": [ + "callout", + "text", + "metric" + ], + "required": false, + "maxItems": 4, + "priority": "normal" + }, + { + "id": "source", + "role": "source", + "grid": { + "column": 10, + "row": 6, + "columnSpan": 3, + "rowSpan": 2 + }, + "allowedBlocks": [ + "citation", + "caption" + ], + "required": false, + "maxItems": 3, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "table", + "insight", + "source" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.54, + "maxOccupiedRatio": 0.88 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "code-focus", + "name": "Code Focus", + "category": "Technical", + "purpose": "Readable code with line emphasis and explanation", + "density": "medium", + "recommendedBlocks": [ + "code", + "text" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "code", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 8, + "rowSpan": 5 + }, + "allowedBlocks": [ + "code", + "terminal-demo" + ], + "required": true, + "maxItems": 1, + "priority": "normal" + }, + { + "id": "explanation", + "role": "support", + "grid": { + "column": 9, + "row": 3, + "columnSpan": 4, + "rowSpan": 5 + }, + "allowedBlocks": [ + "text", + "callout", + "bullets" + ], + "required": false, + "maxItems": 5, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "code", + "explanation" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.5, + "maxOccupiedRatio": 0.86 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "browser-demo", + "name": "Browser Demo", + "category": "Demo", + "purpose": "Live or simulated browser experience with callouts", + "density": "high", + "recommendedBlocks": [ + "browser-demo" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "demo", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 9, + "rowSpan": 5 + }, + "allowedBlocks": [ + "browser-demo", + "embed", + "video", + "image" + ], + "required": true, + "maxItems": 1, + "priority": "normal" + }, + { + "id": "callouts", + "role": "support", + "grid": { + "column": 10, + "row": 3, + "columnSpan": 3, + "rowSpan": 5 + }, + "allowedBlocks": [ + "text", + "callout", + "caption" + ], + "required": false, + "maxItems": 5, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "demo", + "callouts" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.54, + "maxOccupiedRatio": 0.88 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "terminal-demo", + "name": "Terminal Demo", + "category": "Demo", + "purpose": "Command sequence and result in a terminal shell", + "density": "high", + "recommendedBlocks": [ + "terminal-demo", + "code" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "terminal", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 8, + "rowSpan": 5 + }, + "allowedBlocks": [ + "terminal-demo", + "code" + ], + "required": true, + "maxItems": 1, + "priority": "normal" + }, + { + "id": "steps", + "role": "support", + "grid": { + "column": 9, + "row": 3, + "columnSpan": 4, + "rowSpan": 5 + }, + "allowedBlocks": [ + "bullets", + "process", + "text" + ], + "required": false, + "maxItems": 5, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "terminal", + "steps" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.5, + "maxOccupiedRatio": 0.86 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "device-showcase", + "name": "Device Showcase", + "category": "Product", + "purpose": "App screens inside device frames with annotations", + "density": "medium", + "recommendedBlocks": [ + "device", + "image" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "devices", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 8, + "rowSpan": 5 + }, + "allowedBlocks": [ + "device", + "image", + "gallery" + ], + "required": true, + "maxItems": 3, + "priority": "normal" + }, + { + "id": "features", + "role": "support", + "grid": { + "column": 9, + "row": 3, + "columnSpan": 4, + "rowSpan": 5 + }, + "allowedBlocks": [ + "text", + "callout", + "bullets" + ], + "required": false, + "maxItems": 5, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "devices", + "features" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.48, + "maxOccupiedRatio": 0.86 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "gallery", + "name": "Gallery", + "category": "Creative", + "purpose": "Curated image or artifact grid with consistent crops", + "density": "medium", + "recommendedBlocks": [ + "gallery" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "gallery", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 12, + "rowSpan": 4 + }, + "allowedBlocks": [ + "gallery", + "image" + ], + "required": true, + "maxItems": 8, + "priority": "normal" + }, + { + "id": "caption", + "role": "support", + "grid": { + "column": 2, + "row": 7, + "columnSpan": 10, + "rowSpan": 1 + }, + "allowedBlocks": [ + "caption", + "text" + ], + "required": false, + "maxItems": 2, + "priority": "normal", + "contentBudget": { + "maxCharacters": 180, + "maxLines": 2 + } + } + ], + "responsiveOrder": [ + "kicker", + "title", + "gallery", + "caption" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.54, + "maxOccupiedRatio": 0.9 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "team", + "name": "Team", + "category": "People", + "purpose": "Team members with roles, not decorative headshots", + "density": "medium", + "recommendedBlocks": [ + "people-grid" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "members", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 12, + "rowSpan": 4 + }, + "allowedBlocks": [ + "people-grid", + "image", + "text" + ], + "required": true, + "maxItems": 6, + "priority": "normal" + }, + { + "id": "context", + "role": "support", + "grid": { + "column": 2, + "row": 7, + "columnSpan": 10, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text", + "callout" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 160, + "maxLines": 2 + } + } + ], + "responsiveOrder": [ + "kicker", + "title", + "members", + "context" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.5, + "maxOccupiedRatio": 0.88 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "logo-wall", + "name": "Logo Wall", + "category": "Proof", + "purpose": "Customer or partner logos with grouped meaning", + "density": "low", + "recommendedBlocks": [ + "logo-wall" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "logos", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 12, + "rowSpan": 4 + }, + "allowedBlocks": [ + "logo-wall", + "gallery", + "image" + ], + "required": true, + "maxItems": 18, + "priority": "normal" + }, + { + "id": "meaning", + "role": "support", + "grid": { + "column": 2, + "row": 7, + "columnSpan": 10, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text", + "caption" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 160, + "maxLines": 2 + } + } + ], + "responsiveOrder": [ + "kicker", + "title", + "logos", + "meaning" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.48, + "maxOccupiedRatio": 0.88 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "qna", + "name": "Qna", + "category": "Closing", + "purpose": "Question prompt with supporting context", + "density": "low", + "recommendedBlocks": [ + "heading", + "text" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "context", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 80, + "maxLines": 1 + } + }, + { + "id": "prompt", + "role": "title", + "grid": { + "column": 2, + "row": 2, + "columnSpan": 9, + "rowSpan": 3 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 70, + "maxLines": 2 + } + }, + { + "id": "support", + "role": "support", + "grid": { + "column": 2, + "row": 5, + "columnSpan": 7, + "rowSpan": 2 + }, + "allowedBlocks": [ + "text", + "callout" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 180, + "maxLines": 4 + } + }, + { + "id": "contact", + "role": "footer", + "grid": { + "column": 2, + "row": 7, + "columnSpan": 7, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text", + "caption" + ], + "required": false, + "maxItems": 3, + "priority": "normal", + "contentBudget": { + "maxCharacters": 140, + "maxLines": 2 + } + }, + { + "id": "visual", + "role": "visual", + "grid": { + "column": 10, + "row": 3, + "columnSpan": 3, + "rowSpan": 3 + }, + "allowedBlocks": [ + "icon", + "image" + ], + "required": false, + "maxItems": 1, + "priority": "normal" + } + ], + "responsiveOrder": [ + "context", + "prompt", + "support", + "visual", + "contact" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.28, + "maxOccupiedRatio": 0.66 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "summary", + "name": "Summary", + "category": "Closing", + "purpose": "Three to five takeaways prioritized by importance", + "density": "medium", + "recommendedBlocks": [ + "bullets", + "callout" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "takeaways", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 8, + "rowSpan": 5 + }, + "allowedBlocks": [ + "bullets", + "callout", + "text" + ], + "required": true, + "maxItems": 5, + "priority": "normal" + }, + { + "id": "action", + "role": "secondary", + "grid": { + "column": 9, + "row": 3, + "columnSpan": 4, + "rowSpan": 5 + }, + "allowedBlocks": [ + "callout", + "metric", + "process", + "text" + ], + "required": false, + "maxItems": 4, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "takeaways", + "action" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.46, + "maxOccupiedRatio": 0.82 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "references", + "name": "References", + "category": "Closing", + "purpose": "Readable sources, links, and methodology notes", + "density": "high", + "recommendedBlocks": [ + "citations" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "sources", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 12, + "rowSpan": 5 + }, + "allowedBlocks": [ + "citation", + "text", + "table" + ], + "required": true, + "maxItems": 18, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "sources" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.48, + "maxOccupiedRatio": 0.88 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "closing-cta", + "name": "Closing Cta", + "category": "Closing", + "purpose": "Final action, owner, next date, and contact path", + "density": "low", + "recommendedBlocks": [ + "heading", + "callout" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "context", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 80, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 8, + "rowSpan": 2 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 75, + "maxLines": 2 + } + }, + { + "id": "action", + "role": "primary", + "grid": { + "column": 1, + "row": 4, + "columnSpan": 7, + "rowSpan": 2 + }, + "allowedBlocks": [ + "callout", + "text", + "process" + ], + "required": true, + "maxItems": 3, + "priority": "normal" + }, + { + "id": "owner-date", + "role": "support", + "grid": { + "column": 1, + "row": 6, + "columnSpan": 7, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text", + "caption" + ], + "required": false, + "maxItems": 3, + "priority": "normal", + "contentBudget": { + "maxCharacters": 150, + "maxLines": 2 + } + }, + { + "id": "contact", + "role": "footer", + "grid": { + "column": 1, + "row": 7, + "columnSpan": 7, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text", + "citation" + ], + "required": false, + "maxItems": 3, + "priority": "normal", + "contentBudget": { + "maxCharacters": 140, + "maxLines": 2 + } + }, + { + "id": "visual", + "role": "visual", + "grid": { + "column": 9, + "row": 2, + "columnSpan": 4, + "rowSpan": 5 + }, + "allowedBlocks": [ + "image", + "video", + "gallery", + "browser-demo", + "device", + "embed", + "diagram", + "chart", + "map", + "code", + "terminal-demo", + "icon" + ], + "required": false, + "maxItems": 1, + "priority": "normal" + } + ], + "responsiveOrder": [ + "context", + "title", + "action", + "owner-date", + "visual", + "contact" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.34, + "maxOccupiedRatio": 0.74 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + } +] diff --git a/skills/deckforge/starter-components/deck/layout.ts b/skills/deckforge/starter-components/deck/layout.ts new file mode 100644 index 0000000..960b972 --- /dev/null +++ b/skills/deckforge/starter-components/deck/layout.ts @@ -0,0 +1,382 @@ +import layoutManifest from './layout-manifest.json'; +import type { Block, DeckProject, DeckSlide, Frame, LayoutBinding } from './types'; + +/** + * Typed slot roles for semantic block-slot matching. + * + * Use these roles instead of arbitrary string IDs to ensure + * deterministic compatibility between blocks and slots. + */ +export type SlotRole = + | 'title' + | 'subtitle' + | 'kicker' + | 'body' + | 'visual' + | 'chart' + | 'callout' + | 'image' + | 'process' + | 'citation' + | 'footer' + | 'context' + | 'metric' + | 'meaning' + | 'evidence' + | 'caption' + | 'meta' + | 'steps' + | 'decision' + | 'support' + | 'left' + | 'right' + | 'column-1' + | 'column-2' + | 'column-3' + | 'content' + | 'accent' + | 'devices' + | 'gallery' + | 'demo' + | 'map' + | 'option-a' + | 'option-b' + | 'before' + | 'after' + | 'impact' + | 'takeaway' + | 'outcome' + | 'interpretation' + | 'meaning-alt' + | 'timeline' + | 'source' + | 'contact' + | string; // Allow custom roles for extensibility + +export interface LayoutSlotContract { + id: string; + role: SlotRole; + grid: { column: number; row: number; columnSpan: number; rowSpan: number }; + allowedBlocks?: string[]; + required?: boolean; + maxItems?: number; + priority?: string; + contentBudget?: { maxCharacters?: number; maxLines?: number }; +} + +export interface LayoutCompositionContract { + grid: { columns: number; rows: number; columnGap: number; rowGap: number }; + slots: LayoutSlotContract[]; + responsiveOrder?: string[]; + collisionPolicy?: { + mode?: string; + allowedOverlapRoles?: string[]; + maxIncidentalOverlapRatio?: number; + }; + whitespaceTarget?: { minOccupiedRatio?: number; maxOccupiedRatio?: number }; + freeformAllowed?: boolean; +} + +export interface LayoutContract { + id: string; + name: string; + category: string; + purpose: string; + density: string; + composition: LayoutCompositionContract; + defaultPositionMode?: string; + freeformPolicy?: string; + responsiveRule?: string; +} + +export interface ResolvedFrame extends Frame { + slot: string; + role: string; +} + +export type { LayoutBinding }; + +export function getLayoutContract(layoutId: string): LayoutContract | undefined { + return (layoutManifest as LayoutContract[]).find((layout) => layout.id === layoutId); +} + +export function listLayouts(): LayoutContract[] { + return layoutManifest as LayoutContract[]; +} + +/** + * Resolve a slot's grid geometry into canvas coordinates. + * Mirrors scripts/audits/audit_deck_layout.py's resolve_slot so editor rendering + * matches the deterministic audit exactly. + */ +export function resolveSlotFrame( + slot: LayoutSlotContract, + canvas: DeckProject['canvas'], +): Frame { + const safe = canvas.safeMargin ?? 64; + const w = canvas.width ?? 1600; + const h = canvas.height ?? 900; + const innerW = w - 2 * safe; + const innerH = h - 2 * safe; + const cg = 0.35; + const rg = 0.3; + const colGap = 16 * (cg / 0.35); + const rowGap = 16 * (rg / 0.3); + const unitW = (innerW - colGap * 11) / 12; + const unitH = (innerH - rowGap * 7) / 8; + const g = slot.grid; + const x = safe + (g.column - 1) * (unitW + colGap); + const y = safe + (g.row - 1) * (unitH + rowGap); + const sw = g.columnSpan * unitW + (g.columnSpan - 1) * colGap; + const sh = g.rowSpan * unitH + (g.rowSpan - 1) * rowGap; + return { x: Math.round(x), y: Math.round(y), w: Math.round(sw), h: Math.round(sh) }; +} + +export interface ResolvedSlot { + slot: LayoutSlotContract; + frame: Frame; +} + +/** + * Resolve all slots for a layout into frames. Slots are returned in + * responsiveOrder (falling back to manifest order) so reading order is + * deterministic and not coordinate-driven. + */ +export function resolveLayout( + layoutId: string, + canvas: DeckProject['canvas'], +): ResolvedSlot[] { + const contract = getLayoutContract(layoutId); + if (!contract?.composition) return []; + const slots = contract.composition.slots; + const order = contract.composition.responsiveOrder ?? slots.map((slot) => slot.id); + const byId = new Map(slots.map((slot) => [slot.id, slot])); + const ordered = order + .map((id) => byId.get(id)) + .filter((slot): slot is LayoutSlotContract => Boolean(slot)); + for (const slot of slots) { + if (!ordered.includes(slot)) ordered.push(slot); + } + return ordered.map((slot) => ({ slot, frame: resolveSlotFrame(slot, canvas) })); +} + +export interface BlockPlacement { + blockId: string; + slotId: string; + slot: LayoutSlotContract; + frame: Frame; +} + +/** Slot/flow layer entry: a block bound to a semantic slot frame. */ +export interface SlotFlowEntry { + block: Block; + placement: BlockPlacement; +} + +/** Freeform layer entry: an explicitly positioned block on its own frame. */ +export interface FreeformEntry { + block: Block; + frame: Frame; +} + +/** Background layer entry: a full-canvas (or framed) block rendered behind slots. */ +export interface BackgroundEntry { + block: Block; + frame?: Frame; +} + +/** + * Exclusive rendering layers (P0-005): each block id appears in EXACTLY ONE + * bucket. Layer order is background → semantic slot/flow → freeform, with a + * system overlay rendered separately by the host. + */ +export interface LayerAssignment { + background: BackgroundEntry[]; + slotFlow: SlotFlowEntry[]; + freeform: FreeformEntry[]; +} + +/** Position modes that must never participate in the semantic slot pass. */ +const NON_SLOT_MODES: ReadonlySet = new Set(['freeform', 'background']); + +/** + * Bind blocks to slot frames for a slide using its layoutBindings. + * Returns placements in responsive (slot) order for stable reading order. + * + * Blocks whose positionMode is 'freeform' or 'background' are excluded from + * the slot pass even when a binding lists them, so they can never be placed on + * the slot layer (P0-005). + */ +export function resolveSlidePlacements( + slide: DeckSlide, + canvas: DeckProject['canvas'], +): BlockPlacement[] { + const byId = new Map(slide.blocks.map((block) => [block.id, block])); + const resolved = resolveLayout(slide.layout, canvas); + const bySlot = new Map(resolved.map((entry) => [entry.slot.id, entry])); + const bindingMap = new Map(); + for (const binding of slide.layoutBindings ?? []) { + bindingMap.set(binding.slot, binding); + } + const placements: BlockPlacement[] = []; + for (const entry of resolved) { + const binding = bindingMap.get(entry.slot.id); + if (!binding) continue; + for (const blockId of binding.blockIds) { + const block = byId.get(blockId); + if (block && NON_SLOT_MODES.has(block.positionMode ?? '')) continue; + placements.push({ + blockId, + slotId: entry.slot.id, + slot: entry.slot, + frame: entry.frame, + }); + } + } + return placements; +} + +/** + * Assign every block on a slide to exactly one rendering layer (P0-005): + * + * - `slotFlow` — blocks bound to semantic slots (positionMode slot/flow). + * - `freeform` — blocks with positionMode 'freeform', at their own frame. + * - `background` — blocks with positionMode 'background', full-canvas or framed. + * + * The invariant is that each block id lands in exactly one bucket. A block + * listed in a binding but flagged freeform/background stays off the slot pass. + * Violations (double assignment, missing frame, or orphan blocks) are reported + * via console.error so renderers stay resilient while the invariant is visible. + */ +export function assignBlocksToLayers( + slide: DeckSlide, + canvas: DeckProject['canvas'], +): LayerAssignment { + const byId = new Map(slide.blocks.map((block) => [block.id, block])); + const background: BackgroundEntry[] = []; + const slotFlow: SlotFlowEntry[] = []; + const freeform: FreeformEntry[] = []; + + const assigned = new Set(); + const mark = (block: Block): boolean => { + if (assigned.has(block.id)) { + console.error(`[deckforge] P0-005 invariant violation: block "${block.id}" assigned to more than one layer`); + return false; + } + assigned.add(block.id); + return true; + }; + + for (const placement of resolveSlidePlacements(slide, canvas)) { + const block = byId.get(placement.blockId); + if (!block || !mark(block)) continue; + slotFlow.push({ block, placement }); + } + + for (const block of slide.blocks) { + if (block.positionMode !== 'freeform') continue; + const frame = block.frame; + if (!frame) { + console.error(`[deckforge] P0-005 invariant violation: freeform block "${block.id}" has no frame`); + continue; + } + if (!mark(block)) continue; + freeform.push({ block, frame }); + } + + for (const block of slide.blocks) { + if (block.positionMode !== 'background') continue; + if (!mark(block)) continue; + background.push({ block, frame: block.frame }); + } + + for (const block of slide.blocks) { + if (!assigned.has(block.id)) { + console.error(`[deckforge] P0-005 invariant violation: block "${block.id}" was not assigned to any layer`); + } + } + + return { background, slotFlow, freeform }; +} + +/** Returns the frame a specific block resolves to, if bound. */ +export function resolveBlockFrame( + slide: DeckSlide, + canvas: DeckProject['canvas'], + blockId: string, +): Frame | undefined { + return resolveSlidePlacements(slide, canvas).find((placement) => placement.blockId === blockId)?.frame; +} + +/** Warnings for the editor: empty required slots, over-budget slots. */ +/** + * Pick the best slot to bind a newly inserted block to. Prefers the first + * responsive slot whose `allowedBlocks` accepts the type and that still has + * room (maxItems not reached). Next prefers a type-compatible slot even when + * it is at capacity (soft overflow), so a new block never lands in a slot + * that rejects its type (e.g. an image in a text-only band producing a + * degenerate frame). Falls back to the first slot with room so an insert + * always renders instead of disappearing into state-only. + */ +export function suggestSlotForBlock(slide: DeckSlide, block: Block): string | undefined { + const contract = getLayoutContract(slide.layout); + if (!contract?.composition?.slots.length) return undefined; + const bindings = new Map(); + for (const binding of slide.layoutBindings ?? []) bindings.set(binding.slot, binding); + const order = contract.composition.responsiveOrder ?? contract.composition.slots.map((slot) => slot.id); + const ordered = [...order, ...contract.composition.slots.map((slot) => slot.id)]; + const seen = new Set(); + const slots: LayoutSlotContract[] = []; + for (const id of ordered) { + if (seen.has(id)) continue; + seen.add(id); + const slot = contract.composition.slots.find((candidate) => candidate.id === id); + if (slot) slots.push(slot); + } + const hasRoom = (slot: LayoutSlotContract): boolean => { + const count = bindings.get(slot.id)?.blockIds.length ?? 0; + return slot.maxItems == null || count < slot.maxItems; + }; + const allows = (slot: LayoutSlotContract): boolean => + !slot.allowedBlocks?.length || slot.allowedBlocks.includes(block.type); + return ( + slots.find((slot) => allows(slot) && hasRoom(slot))?.id ?? + slots.find((slot) => allows(slot))?.id ?? + slots.find(hasRoom)?.id + ); +} + +export interface LayoutIssue { + severity: 'warning' | 'error'; + slot: string; + message: string; +} + +export function auditSlideLayout( + slide: DeckSlide, + canvas: DeckProject['canvas'], +): LayoutIssue[] { + const issues: LayoutIssue[] = []; + const resolved = resolveLayout(slide.layout, canvas); + const bindings = new Map(); + for (const binding of slide.layoutBindings ?? []) bindings.set(binding.slot, binding); + for (const entry of resolved) { + const binding = bindings.get(entry.slot.id); + const count = binding?.blockIds.length ?? 0; + if (entry.slot.required && count === 0) { + issues.push({ + severity: 'error', + slot: entry.slot.id, + message: `Required slot "${entry.slot.id}" is empty`, + }); + } + if (entry.slot.maxItems != null && count > entry.slot.maxItems) { + issues.push({ + severity: 'warning', + slot: entry.slot.id, + message: `Slot "${entry.slot.id}" has ${count} blocks, max is ${entry.slot.maxItems}`, + }); + } + } + return issues; +} diff --git a/skills/deckforge/starter-components/deck/scrollbars/scrollbarTypes.ts b/skills/deckforge/starter-components/deck/scrollbars/scrollbarTypes.ts new file mode 100644 index 0000000..3e8fe0c --- /dev/null +++ b/skills/deckforge/starter-components/deck/scrollbars/scrollbarTypes.ts @@ -0,0 +1,37 @@ +export type ScrollSurface = + | 'app-page' + | 'slide-list' + | 'inspector' + | 'grid' + | 'speaker-notes' + | 'modal' + | 'asset-library' + | 'theme-library' + | 'presenter' + | 'slide-stage'; + +export type ScrollbarStyleId = + | 'gradient-slim' + | 'aurora-glow' + | 'minimal-thin' + | 'neon-edge' + | 'mono-ink' + | 'high-contrast' + | 'system-native' + | 'none'; + +export type ScrollAxis = 'vertical' | 'horizontal' | 'both'; + +export interface ScrollbarThemeMapping { + default: ScrollbarStyleId; + 'app-page'?: ScrollbarStyleId; + 'slide-list'?: ScrollbarStyleId; + inspector?: ScrollbarStyleId; + grid?: ScrollbarStyleId; + 'speaker-notes'?: ScrollbarStyleId; + modal?: ScrollbarStyleId; + 'asset-library'?: ScrollbarStyleId; + 'theme-library'?: ScrollbarStyleId; + presenter: 'none'; + 'slide-stage': 'none'; +} diff --git a/skills/deckforge/starter-components/deck/slot-validation.ts b/skills/deckforge/starter-components/deck/slot-validation.ts new file mode 100644 index 0000000..f2768e4 --- /dev/null +++ b/skills/deckforge/starter-components/deck/slot-validation.ts @@ -0,0 +1,469 @@ +/** + * deck/slot-validation.ts + * + * Creation-time validation and auto-repair for slot-positioned blocks. + * + * This module ensures that every block with positionMode "slot" satisfies + * the strict positioning contract BEFORE export: + * + * 1. slotId exists on the block + * 2. slotId references a slot in the active layout + * 3. The slot accepts the block type (allowedBlocks) + * 4. The slot has remaining capacity (maxItems) + * + * Invariants: + * - Never persist a block with positionMode "slot" and no valid slotId + * - Never persist a block referencing a nonexistent slot + * - Auto-repair is deterministic and uses the same logic as the runtime resolver + * - Preflight should normally pass immediately for a correctly generated deck + */ + +import type { Block, DeckProject, DeckSlide, LayoutBinding } from './types'; +import { + getLayoutContract, + resolveLayout, + suggestSlotForBlock, + type LayoutSlotContract, +} from './layout'; + +// ─── Error Types ─────────────────────────────────────────────────────────── + +export type SlotValidationErrorKind = + | 'MISSING_SLOT_ID' + | 'UNKNOWN_SLOT' + | 'SLOT_TYPE_MISMATCH' + | 'SLOT_CAPACITY_EXCEEDED' + | 'MISSING_LAYOUT' + | 'MISSING_FRAME' + | 'NON_FINITE_GEOMETRY' + | 'INVALID_SIZE'; + +export interface SlotValidationError { + kind: SlotValidationErrorKind; + blockId: string; + blockType: string; + slotId?: string; + layoutId?: string; + message: string; + /** The slot role the block was trying to target, if determinable. */ + requestedRole?: string; + /** Available slots that could accept this block type. */ + availableSlots?: string[]; +} + +export interface BlockValidationResult { + valid: boolean; + errors: SlotValidationError[]; + /** The slot the block should be bound to after repair. */ + repairedSlotId?: string; + /** Whether the block's slot property was changed during repair. */ + slotChanged?: boolean; + /** Whether a new binding was created during repair. */ + bindingCreated?: boolean; +} + +export interface SlideValidationResult { + valid: boolean; + blockResults: Map; + totalErrors: number; + /** Repaired slide with corrected bindings. */ + repairedSlide?: DeckSlide; +} + +// ─── Slot Acceptance Logic ───────────────────────────────────────────────── + +/** + * Check if a slot accepts a block type. + * Uses the same logic as seed.ts and geometry-resolver.ts for consistency. + */ +export function slotAccepts(slot: LayoutSlotContract | undefined, type: string): boolean { + return !slot?.allowedBlocks?.length || slot.allowedBlocks.includes(type); +} + +/** + * Check if a slot has remaining capacity. + */ +export function slotHasRoom( + slot: LayoutSlotContract, + currentBindings: Map, +): boolean { + const count = currentBindings.get(slot.id)?.blockIds.length ?? 0; + return slot.maxItems == null || count < slot.maxItems; +} + +// ─── Single Block Validation ─────────────────────────────────────────────── + +/** + * Validate a single block's positioning contract. + * + * Returns a BlockValidationResult with: + * - valid: true if the block satisfies the contract + * - errors: list of validation errors + * - repairedSlotId: the slot the block should be bound to (if repair is possible) + */ +export function validateBlockPositioning( + block: Block, + slide: DeckSlide, + canvas: DeckProject['canvas'], +): BlockValidationResult { + const errors: SlotValidationError[] = []; + const layoutId = slide.layout; + + // Freeform and background blocks are not validated for slot positioning + if (block.positionMode === 'freeform' || block.positionMode === 'background') { + // But they still need valid frames + if (!block.frame) { + const frame = block.resolvedFrame; + if (!frame) { + errors.push({ + kind: 'MISSING_FRAME', + blockId: block.id, + blockType: block.type, + layoutId, + message: `${block.type} block "${block.id}" has positionMode "${block.positionMode}" but no frame`, + }); + } else if (!Number.isFinite(frame.x) || !Number.isFinite(frame.y) || !Number.isFinite(frame.w) || !Number.isFinite(frame.h)) { + errors.push({ + kind: 'NON_FINITE_GEOMETRY', + blockId: block.id, + blockType: block.type, + layoutId, + message: `${block.type} block "${block.id}" has non-finite frame dimensions`, + }); + } else if (frame.w <= 0 || frame.h <= 0) { + errors.push({ + kind: 'INVALID_SIZE', + blockId: block.id, + blockType: block.type, + layoutId, + message: `${block.type} block "${block.id}" has zero or negative frame dimensions`, + }); + } + } + return { valid: errors.length === 0, errors }; + } + + // Slot-positioned blocks need a valid layout + const contract = getLayoutContract(layoutId); + if (!contract?.composition?.slots.length) { + // No layout defined — use suggestSlotForBlock as fallback + const suggestedSlot = suggestSlotForBlock(slide, block); + if (suggestedSlot) { + return { + valid: true, + errors: [], + repairedSlotId: suggestedSlot, + slotChanged: block.slot !== suggestedSlot, + }; + } + errors.push({ + kind: 'MISSING_LAYOUT', + blockId: block.id, + blockType: block.type, + layoutId, + message: `No layout contract found for "${layoutId}"`, + }); + return { valid: false, errors }; + } + + // Check if block has a slot property + if (!block.slot) { + // Auto-repair: find best matching slot + const suggestedSlot = suggestSlotForBlock(slide, block); + if (suggestedSlot) { + return { + valid: true, + errors: [], + repairedSlotId: suggestedSlot, + slotChanged: true, + }; + } + errors.push({ + kind: 'MISSING_SLOT_ID', + blockId: block.id, + blockType: block.type, + layoutId, + message: `${block.type} block "${block.id}" has positionMode "slot" but no slotId`, + }); + return { valid: false, errors }; + } + + // Check if the slot exists in the layout + const slotContract = contract.composition.slots.find((s) => s.id === block.slot); + if (!slotContract) { + // Auto-repair: find best matching slot + const suggestedSlot = suggestSlotForBlock(slide, block); + if (suggestedSlot) { + return { + valid: true, + errors: [], + repairedSlotId: suggestedSlot, + slotChanged: true, + }; + } + errors.push({ + kind: 'UNKNOWN_SLOT', + blockId: block.id, + blockType: block.type, + slotId: block.slot, + layoutId, + message: `Slot "${block.slot}" does not exist in layout "${layoutId}"`, + availableSlots: contract.composition.slots.map((s) => s.id), + }); + return { valid: false, errors }; + } + + // Check if the slot accepts this block type + if (!slotAccepts(slotContract, block.type)) { + // Auto-repair: find best matching slot + const suggestedSlot = suggestSlotForBlock(slide, block); + if (suggestedSlot) { + return { + valid: true, + errors: [], + repairedSlotId: suggestedSlot, + slotChanged: true, + }; + } + errors.push({ + kind: 'SLOT_TYPE_MISMATCH', + blockId: block.id, + blockType: block.type, + slotId: block.slot, + layoutId, + message: `Slot "${block.slot}" does not accept ${block.type} blocks (allowedBlocks: ${slotContract.allowedBlocks?.join(', ') ?? 'any'})`, + availableSlots: contract.composition.slots + .filter((s) => slotAccepts(s, block.type)) + .map((s) => s.id), + }); + return { valid: false, errors }; + } + + // Block satisfies the positioning contract + return { valid: true, errors: [] }; +} + +// ─── Slide Validation ────────────────────────────────────────────────────── + +/** + * Validate all blocks on a slide for slot positioning. + * + * Returns a SlideValidationResult with per-block results and the total error count. + */ +export function validateSlideSlotBindings( + slide: DeckSlide, + canvas: DeckProject['canvas'], +): SlideValidationResult { + const blockResults = new Map(); + let totalErrors = 0; + + for (const block of slide.blocks) { + if (block.hidden) continue; + const result = validateBlockPositioning(block, slide, canvas); + blockResults.set(block.id, result); + totalErrors += result.errors.length; + } + + return { + valid: totalErrors === 0, + blockResults, + totalErrors, + }; +} + +// ─── Auto-Repair ─────────────────────────────────────────────────────────── + +/** + * Repair a single invalid slot block by binding it to the best matching slot. + * + * Returns a new block with the corrected slot property. + */ +export function repairSlotBlock( + block: Block, + slide: DeckSlide, + canvas: DeckProject['canvas'], +): Block { + const suggestedSlot = suggestSlotForBlock(slide, block); + if (!suggestedSlot) return block; + return { ...block, slot: suggestedSlot, positionMode: 'slot' }; +} + +/** + * Repair all invalid slot blocks on a slide. + * + * Returns a new slide with corrected layoutBindings. + * The input slide is never mutated. + */ +export function repairSlideSlotBindings( + slide: DeckSlide, + canvas: DeckProject['canvas'], +): DeckSlide { + const contract = getLayoutContract(slide.layout); + if (!contract?.composition?.slots.length) return slide; + + // Build current bindings map + const bindings = new Map(); + for (const binding of slide.layoutBindings ?? []) { + bindings.set(binding.slot, binding); + } + + // Track which blocks are already bound + const boundBlockIds = new Set(); + for (const binding of slide.layoutBindings ?? []) { + for (const id of binding.blockIds) { + boundBlockIds.add(id); + } + } + + // Find blocks that need repair + const blocksToRepair: Block[] = []; + for (const block of slide.blocks) { + if (block.hidden) continue; + if (block.positionMode === 'freeform' || block.positionMode === 'background') continue; + if (boundBlockIds.has(block.id)) continue; + + const result = validateBlockPositioning(block, slide, canvas); + if (!result.valid || result.repairedSlotId) { + blocksToRepair.push(block); + } + } + + if (blocksToRepair.length === 0) return slide; + + // Repair each block + const repairedBlocks: Block[] = []; + const newBindings = new Map(); + + // Initialize with existing bindings + for (const [slotId, binding] of bindings) { + newBindings.set(slotId, [...binding.blockIds]); + } + + for (const block of blocksToRepair) { + const result = validateBlockPositioning(block, slide, canvas); + const repairedSlot = result.repairedSlotId ?? suggestSlotForBlock(slide, block); + + if (repairedSlot) { + // Add block to the repaired slot's binding + if (!newBindings.has(repairedSlot)) { + newBindings.set(repairedSlot, []); + } + newBindings.get(repairedSlot)!.push(block.id); + + // Add repaired block to the list + repairedBlocks.push({ + ...block, + slot: repairedSlot, + positionMode: 'slot', + }); + } else { + // No slot found — keep block as-is (will be caught by geometry resolver) + repairedBlocks.push(block); + } + } + + // Build new layoutBindings + const layoutBindings: LayoutBinding[] = []; + const slotOrder = contract.composition.responsiveOrder ?? contract.composition.slots.map((s) => s.id); + + for (const slotId of slotOrder) { + const blockIds = newBindings.get(slotId); + if (blockIds && blockIds.length > 0) { + const existingBinding = bindings.get(slotId); + layoutBindings.push({ + slot: slotId, + blockIds, + flow: existingBinding?.flow ?? 'stack', + gap: existingBinding?.gap ?? 8, + }); + } + } + + // Merge repaired blocks with original blocks + const blockById = new Map(slide.blocks.map((b) => [b.id, b])); + for (const repaired of repairedBlocks) { + blockById.set(repaired.id, repaired); + } + + return { + ...slide, + blocks: [...blockById.values()], + layoutBindings, + }; +} + +// ─── Deck Validation ─────────────────────────────────────────────────────── + +/** + * Validate all blocks in a deck for slot positioning. + * + * Returns validation results for each slide. + */ +export function validateDeckSlotBindings( + deck: DeckProject, +): Map { + const canvas = deck.canvas ?? { aspectRatio: '16:9', width: 1600, height: 900, safeMargin: 64 }; + const results = new Map(); + + for (const slide of deck.slides) { + results.set(slide.id, validateSlideSlotBindings(slide, canvas)); + } + + return results; +} + +/** + * Repair all invalid slot blocks in a deck. + * + * Returns a new deck with corrected layoutBindings. + * The input deck is never mutated. + */ +export function repairDeckSlotBindings(deck: DeckProject): DeckProject { + const canvas = deck.canvas ?? { aspectRatio: '16:9', width: 1600, height: 900, safeMargin: 64 }; + return { + ...deck, + slides: deck.slides.map((slide) => repairSlideSlotBindings(slide, canvas)), + }; +} + +// ─── Exportability Gate ───────────────────────────────────────────────────── + +/** + * Check if a deck is exportable (all blocks have valid geometry). + * + * This is a lightweight preflight check that can be run after generation + * to ensure the deck is ready for export without manual repairs. + */ +export function isDeckExportable(deck: DeckProject): { + exportable: boolean; + errors: SlotValidationError[]; + totalBlocks: number; + validBlocks: number; + invalidBlocks: number; +} { + const results = validateDeckSlotBindings(deck); + const allErrors: SlotValidationError[] = []; + let totalBlocks = 0; + let validBlocks = 0; + let invalidBlocks = 0; + + for (const [slideId, result] of results) { + for (const [blockId, blockResult] of result.blockResults) { + totalBlocks++; + if (blockResult.valid) { + validBlocks++; + } else { + invalidBlocks++; + allErrors.push(...blockResult.errors); + } + } + } + + return { + exportable: allErrors.length === 0, + errors: allErrors, + totalBlocks, + validBlocks, + invalidBlocks, + }; +} diff --git a/skills/deckforge/starter-components/deck/themes.ts b/skills/deckforge/starter-components/deck/themes.ts new file mode 100644 index 0000000..2c109a8 --- /dev/null +++ b/skills/deckforge/starter-components/deck/themes.ts @@ -0,0 +1,256 @@ +import type { ThemeDef } from './types'; + +const cream: ThemeDef = { + id: 'editorial-cream', + name: 'Editorial Cream', + category: 'Editorial', + description: 'Magazine-like narrative presentation with cream background', + tokens: { + background: '#FAF3E7', + foreground: '#0F172A', + primary: '#2B2118', + secondary: '#B45309', + surface: '#F1EADF', + muted: '#64748B', + surfaceElevated: '#EAE3D8', + border: '#D7D1C7', + focus: '#B45309', + }, + typography: { headingFont: 'Libre Baskerville', bodyFont: 'Inter', codeFont: 'JetBrains Mono' }, + chartPalette: ['#2B2118', '#B45309', '#15803D', '#C2410C', '#EF4444', '#8B5CF6'], + shapeLanguage: 'soft', + motionStyle: 'cinematic', + scrollbar: { + default: 'minimal-thin', + grid: 'gradient-slim', + 'speaker-notes': 'gradient-slim', + presenter: 'none', + 'slide-stage': 'none', + }, + gradients: { + hero: 'radial-gradient(120% 120% at 80% 0%, #FBE9D2 0%, #FAF3E7 48%, #F4E7D5 100%)', + emphasis: 'linear-gradient(135deg, #F0E3D0 0%, #F6ECDC 100%)', + progress: 'linear-gradient(90deg, #B45309 0%, #D97706 100%)', + highlight: 'linear-gradient(180deg, transparent 62%, #F3D9B0 62%)', + accent: 'linear-gradient(135deg, #B45309 0%, #D97706 100%)', + }, +}; + +const oceanic: ThemeDef = { + id: 'oceanic-blueprint', + name: 'Oceanic Blueprint', + category: 'Architecture', + description: 'Blueprint grid over ocean blues', + tokens: { + background: '#FFFFFF', + foreground: '#0F172A', + primary: '#111827', + secondary: '#06B6D4', + surface: '#F6F6F6', + muted: '#64748B', + surfaceElevated: '#EEEEEE', + border: '#DBDBDB', + focus: '#06B6D4', + }, + typography: { headingFont: 'Manrope', bodyFont: 'Inter', codeFont: 'JetBrains Mono' }, + chartPalette: ['#111827', '#0891B2', '#15803D', '#D97706', '#EF4444', '#8B5CF6'], + shapeLanguage: 'technical', + motionStyle: 'precise', + scrollbar: { + default: 'gradient-slim', + 'slide-list': 'minimal-thin', + presenter: 'none', + 'slide-stage': 'none', + }, + gradients: { + hero: 'radial-gradient(120% 120% at 70% 0%, #E0F7FA 0%, #FFFFFF 55%, #F0FBFC 100%)', + emphasis: 'linear-gradient(135deg, #E8F8FA 0%, #F7FEFF 100%)', + progress: 'linear-gradient(90deg, #0891B2 0%, #06B6D4 100%)', + highlight: 'linear-gradient(180deg, transparent 62%, #C9F2F7 62%)', + accent: 'linear-gradient(135deg, #0891B2 0%, #06B6D4 100%)', + }, +}; + +const research: ThemeDef = { + id: 'research-lab', + name: 'Research Lab', + category: 'Research', + description: 'Academic but modern research slides with precise grids', + tokens: { + background: '#F8FAFC', + foreground: '#0F172A', + primary: '#0F172A', + secondary: '#0EA5E9', + surface: '#EFF1F3', + muted: '#64748B', + surfaceElevated: '#E8EAEC', + border: '#D5D7D9', + focus: '#0EA5E9', + }, + typography: { headingFont: 'IBM Plex Sans', bodyFont: 'Inter', codeFont: 'JetBrains Mono' }, + chartPalette: ['#0F172A', '#0284C7', '#15803D', '#D97706', '#EF4444', '#8B5CF6'], + shapeLanguage: 'soft', + motionStyle: 'cinematic', + scrollbar: { + default: 'minimal-thin', + 'speaker-notes': 'gradient-slim', + presenter: 'none', + 'slide-stage': 'none', + }, + gradients: { + hero: 'radial-gradient(120% 120% at 75% 0%, #E0F2FE 0%, #F8FAFC 55%, #EDF5FC 100%)', + emphasis: 'linear-gradient(135deg, #EAF3FB 0%, #F6FAFD 100%)', + progress: 'linear-gradient(90deg, #0284C7 0%, #0EA5E9 100%)', + highlight: 'linear-gradient(180deg, transparent 62%, #CFE7F8 62%)', + accent: 'linear-gradient(135deg, #0284C7 0%, #0EA5E9 100%)', + }, +}; + +const warm: ThemeDef = { + id: 'warm-product', + name: 'Warm Product', + category: 'Product', + description: 'Soft warm SaaS product storytelling', + tokens: { + background: '#FFF7ED', + foreground: '#0F172A', + primary: '#1F2937', + secondary: '#F97316', + surface: '#F6EEE5', + muted: '#64748B', + surfaceElevated: '#EEE7DE', + border: '#DBD4CC', + focus: '#F97316', + }, + typography: { headingFont: 'Manrope', bodyFont: 'Inter', codeFont: 'JetBrains Mono' }, + chartPalette: ['#1F2937', '#C2410C', '#15803D', '#D97706', '#EF4444', '#8B5CF6'], + shapeLanguage: 'sharp', + motionStyle: 'subtle', + scrollbar: { + default: 'gradient-slim', + presenter: 'none', + 'slide-stage': 'none', + }, + gradients: { + hero: 'radial-gradient(120% 120% at 70% 0%, #FFEDD5 0%, #FFF7ED 55%, #FFF0E0 100%)', + emphasis: 'linear-gradient(135deg, #FDEAD7 0%, #FFF5EC 100%)', + progress: 'linear-gradient(90deg, #EA580C 0%, #F97316 100%)', + highlight: 'linear-gradient(180deg, transparent 62%, #FFDFC2 62%)', + accent: 'linear-gradient(135deg, #EA580C 0%, #F97316 100%)', + }, +}; + +const carbon: ThemeDef = { + id: 'carbon-command', + name: 'Carbon Command', + category: 'Engineering', + description: 'Carbon-black command center for deep technical demos', + tokens: { + background: '#0A0A0A', + foreground: '#F8FAFC', + primary: '#84CC16', + secondary: '#38BDF8', + surface: '#222222', + muted: '#A7B0C0', + surfaceElevated: '#343434', + border: '#474747', + focus: '#38BDF8', + }, + typography: { headingFont: 'JetBrains Mono', bodyFont: 'Inter', codeFont: 'JetBrains Mono' }, + chartPalette: ['#84CC16', '#38BDF8', '#22C55E', '#F59E0B', '#EF4444', '#8B5CF6'], + shapeLanguage: 'technical', + motionStyle: 'precise', + scrollbar: { + default: 'gradient-slim', + 'slide-list': 'minimal-thin', + presenter: 'none', + 'slide-stage': 'none', + }, + gradients: { + hero: 'radial-gradient(120% 120% at 75% 0%, #1E293B 0%, #0A0A0A 55%, #111827 100%)', + emphasis: 'linear-gradient(135deg, #27272A 0%, #1A1A1A 100%)', + progress: 'linear-gradient(90deg, #65A30D 0%, #84CC16 100%)', + highlight: 'linear-gradient(180deg, transparent 62%, #2E3B2E 62%)', + accent: 'linear-gradient(135deg, #65A30D 0%, #84CC16 100%)', + }, +}; + +const monoInk: ThemeDef = { + id: 'mono-ink', + name: 'Mono Ink', + category: 'Minimal', + description: 'Black-and-white consultant elegance', + tokens: { + background: '#FAFAFA', + foreground: '#0F172A', + primary: '#18181B', + secondary: '#71717A', + surface: '#F1F1F1', + muted: '#64748B', + surfaceElevated: '#EAEAEA', + border: '#D7D7D7', + focus: '#71717A', + }, + typography: { headingFont: 'Sora', bodyFont: 'Inter', codeFont: 'JetBrains Mono' }, + chartPalette: ['#18181B', '#71717A', '#15803D', '#D97706', '#EF4444', '#8B5CF6'], + shapeLanguage: 'editorial', + motionStyle: 'snappy', + scrollbar: { + default: 'mono-ink', + presenter: 'none', + 'slide-stage': 'none', + }, + gradients: { + hero: 'radial-gradient(120% 120% at 80% 0%, #F4F4F5 0%, #FAFAFA 55%, #F0F0F0 100%)', + emphasis: 'linear-gradient(135deg, #ECECEC 0%, #F7F7F7 100%)', + progress: 'linear-gradient(90deg, #3F3F46 0%, #71717A 100%)', + highlight: 'linear-gradient(180deg, transparent 62%, #DDDDE1 62%)', + accent: 'linear-gradient(135deg, #3F3F46 0%, #71717A 100%)', + }, +}; + +const greenfield: ThemeDef = { + id: 'greenfield-growth', + name: 'Greenfield Growth', + category: 'Climate', + description: 'Green innovation and sustainability', + tokens: { + background: '#022C22', + foreground: '#F8FAFC', + primary: '#34D399', + secondary: '#A7F3D0', + surface: '#1B4138', + muted: '#A7B0C0', + surfaceElevated: '#2D5048', + border: '#416159', + focus: '#A7F3D0', + }, + typography: { headingFont: 'IBM Plex Sans', bodyFont: 'Inter', codeFont: 'JetBrains Mono' }, + chartPalette: ['#34D399', '#A7F3D0', '#22C55E', '#F59E0B', '#EF4444', '#8B5CF6'], + shapeLanguage: 'soft', + motionStyle: 'cinematic', + scrollbar: { + default: 'gradient-slim', + presenter: 'none', + 'slide-stage': 'none', + }, + gradients: { + hero: 'radial-gradient(120% 120% at 75% 0%, #0B3B2E 0%, #022C22 55%, #063528 100%)', + emphasis: 'linear-gradient(135deg, #1B4138 0%, #12342C 100%)', + progress: 'linear-gradient(90deg, #10B981 0%, #34D399 100%)', + highlight: 'linear-gradient(180deg, transparent 62%, #1E4A3D 62%)', + accent: 'linear-gradient(135deg, #10B981 0%, #34D399 100%)', + }, +}; + +const THEMES: ThemeDef[] = [cream, oceanic, research, warm, carbon, monoInk, greenfield]; + +const THEME_INDEX = new Map(THEMES.map((theme) => [theme.id, theme])); + +export function getTheme(id: string): ThemeDef { + return THEME_INDEX.get(id) ?? cream; +} + +export function listThemes(): ThemeDef[] { + return THEMES; +} diff --git a/skills/deckforge/starter-components/deck/types.ts b/skills/deckforge/starter-components/deck/types.ts new file mode 100644 index 0000000..0bb3253 --- /dev/null +++ b/skills/deckforge/starter-components/deck/types.ts @@ -0,0 +1,319 @@ +import type { ReactNode } from 'react'; +import type { ScrollbarThemeMapping } from './scrollbars/scrollbarTypes'; + +export type PositionMode = 'slot' | 'flow' | 'freeform' | 'background'; +export type FitPolicy = 'wrap' | 'contain' | 'cover' | 'scroll' | 'change-layout' | 'split-slide'; + +export interface Frame { + x: number; + y: number; + w: number; + h: number; + rotation?: number; + z?: number; +} + +export interface BlockAnimation { + id: string; + trigger?: 'on-enter' | 'on-click' | 'with-previous' | 'after-previous'; + order?: number; + durationMs?: number; + delayMs?: number; + easing?: string; + reducedMotionFallback?: string; +} + +export interface BlockStyle { + variant?: string; + level?: number; + align?: string; + [key: string]: unknown; +} + +export interface ChartValue { + label: string; + value: number; +} + +export interface ChartContent { + type: 'bar' | 'bar-horizontal' | 'line'; + title?: string; + unit?: string; + values: ChartValue[]; + highlightIndex?: number; + summary?: string; + /** True when the chart is still a starter "New chart" template from the + * editor's block palette, not real authored content. Template charts are + * excluded from export rather than leaking placeholder data. */ + isTemplate?: boolean; +} + +export type AssetKind = 'image' | 'video' | 'audio' | 'font' | 'data' | 'document' | 'model' | 'embed-poster'; + +/** Asset manifest entry (plan §9.1 AssetManifestItem). */ +export interface DeckAsset { + id: string; + kind: AssetKind; + src: string; + mimeType?: string; + width?: number; + height?: number; + durationMs?: number; + alt?: string; + credit?: string; + license?: string; + integrity?: string; + posterSrc?: string; + transcriptSrc?: string; + focalPoint?: { x: number; y: number }; + status?: 'ready' | 'failed' | 'placeholder'; +} + +/** Content shape for image blocks (plan §9.2 ImageBlockData). */ +export interface ImageBlockContent { + assetId?: string; + src?: string; + fit?: 'cover' | 'contain'; + focalPoint?: { x: number; y: number }; + caption?: string; + attribution?: string; + alt?: string; + decorative?: boolean; + rounded?: boolean; +} + +export interface MetricContent { + value: string; + label?: string; + delta?: string; +} + +export interface ProcessStep { + title: string; + detail?: string; +} + +export interface Block { + id: string; + type: string; + content: unknown; + frame?: Frame; + style?: BlockStyle; + data?: Record; + alt?: string; + ariaLabel?: string; + sourceIds?: string[]; + animation?: BlockAnimation; + locked?: boolean; + hidden?: boolean; + role?: string; + slot?: string; + positionMode?: PositionMode; + fitPolicy?: FitPolicy; + resolvedFrame?: Frame; + decorative?: boolean; + allowOverlap?: boolean; +} + +export interface LayoutBinding { + slot: string; + blockIds: string[]; + flow?: 'stack' | 'row' | 'grid' | 'overlay'; + gap?: number; +} + +export interface SlideInteraction { + id: string; + type: string; + trigger: string; + action: string; + payload?: unknown; + ariaLabel?: string; +} + +export interface DeckSlide { + id: string; + title: string; + layout: string; + hidden?: boolean; + section?: string; + background?: Record; + blocks: Block[]; + speakerNotes?: string; + sources?: string[]; + interactions?: SlideInteraction[]; + transition?: string; + durationMs?: number; + tags?: string[]; + layoutVariant?: string; + layoutBindings?: LayoutBinding[]; + density?: 'low' | 'medium' | 'high'; + focalBlockId?: string; +} + +export interface SourceRef { + id: string; + title: string; + url: string; + authors?: string[]; + publisher?: string; + publishedAt?: string; + accessedAt?: string; + note?: string; + license?: string; +} + +export interface ThemeTokens { + background: string; + foreground: string; + primary: string; + secondary: string; + surface: string; + muted: string; + surfaceElevated: string; + border: string; + focus: string; +} + +/** + * Approved gradient uses (plan §10.3): hero backgrounds, small emphasis + * surfaces, progress bars, highlight sweeps, and decorative accents. + * Gradients must never cover body paragraphs, bullet lists, or data tables. + */ +export interface ThemeGradients { + hero?: string; + emphasis?: string; + progress?: string; + highlight?: string; + accent?: string; +} + +export interface ThemeDef { + id: string; + name: string; + category?: string; + description?: string; + tokens: ThemeTokens; + typography: { headingFont: string; bodyFont: string; codeFont: string }; + mood?: string; + chartPalette: string[]; + shapeLanguage?: string; + motionStyle?: string; + gradients?: ThemeGradients; + antiPatterns?: string[]; + scrollbar?: ScrollbarThemeMapping; +} + +export interface DeckProject { + schemaVersion: string; + meta: { + id: string; + slug: string; + title: string; + description?: string; + language: string; + audience?: string; + objective?: string; + templateId?: string; + authors?: string[]; + tags?: string[]; + createdAt?: string; + updatedAt?: string; + }; + canvas: { + aspectRatio: '16:9' | '4:3' | 'custom'; + width: number; + height: number; + safeMargin: number; + grid?: number; + responsiveMode?: 'letterbox' | 'reflow' | 'hybrid'; + layoutMode?: 'semantic-slots' | 'hybrid' | 'freeform'; + background?: string; + }; + theme: { id: string; overrides?: Record; designSystemRef?: string; mode?: string }; + presentation: { + mode?: string; + transition?: string; + motionProfileId?: string; + defaultBuilds?: boolean; + keyboard?: boolean; + touch?: boolean; + deepLinks?: boolean; + overview?: boolean; + speakerView?: boolean; + progress?: boolean; + controls?: boolean; + reducedMotion?: 'respect-system' | 'always' | 'never'; + autoplay?: { enabled: boolean; intervalMs?: number; loop?: boolean; pauseOnInteraction?: boolean }; + }; + editor: { + enabled: boolean; + toolbar?: boolean; + history?: boolean; + snapToGrid?: boolean; + guides?: boolean; + comments?: boolean; + collaboration?: boolean; + autosave?: boolean; + commandPalette?: boolean; + notes?: boolean; + allowedBlockTypes?: string[]; + sidePanel?: boolean; + assetLibrary?: boolean; + themePicker?: boolean; + layoutPicker?: boolean; + shortcutHelp?: boolean; + saveStatus?: boolean; + persistence?: 'none' | 'local-storage' | 'api' | 'host-managed'; + routes?: Record; + requiredZones?: string[]; + }; + assets?: DeckAsset[]; + slides: DeckSlide[]; + sources?: SourceRef[]; + publish?: { + visibility?: string; + slug?: string; + embed?: { enabled: boolean; allowedOrigins?: string[]; sandbox?: string[]; responsive?: boolean }; + analytics?: boolean; + allowDownload?: boolean; + }; + experience?: { + profile: string; + surfaces: string[]; + routes?: Record; + capabilities?: string[]; + }; + shortcuts?: { + helpEnabled?: boolean; + helpKey?: string; + editorPreset?: string; + presenterPreset?: string; + overrides?: Record; + }; +} + +export type SaveState = 'clean' | 'dirty' | 'saving' | 'saved' | 'failed' | 'offline' | 'conflict'; + +export interface EditorSelection { + slideId: string; + blockIds: string[]; + mode: 'block' | 'slide' | 'none'; +} + +export type Route = 'editor' | 'present'; + +export interface PresenterBuildState { + slideIndex: number; + step: number; +} + +export type RenderBlockProps = { + block: Block; + deck: DeckProject; + slide: DeckSlide; + editing?: boolean; + selected?: boolean; + onSelect?: (id: string, additive: boolean) => void; + renderNode?: (block: Block) => ReactNode; +}; From 06a6e3b20d191b30ffb2e2a52653056583607abd Mon Sep 17 00:00:00 2001 From: tph-kds Date: Sat, 15 Aug 2026 03:43:21 +0700 Subject: [PATCH 02/16] feat(scaffold): vendor trimmed seed and command dispatch from 02-example --- .../starter-components/deck/commands.ts | 439 ++++++++++++++++++ .../deckforge/starter-components/deck/seed.ts | 279 +++++++++++ 2 files changed, 718 insertions(+) create mode 100644 skills/deckforge/starter-components/deck/commands.ts create mode 100644 skills/deckforge/starter-components/deck/seed.ts diff --git a/skills/deckforge/starter-components/deck/commands.ts b/skills/deckforge/starter-components/deck/commands.ts new file mode 100644 index 0000000..b0c3b40 --- /dev/null +++ b/skills/deckforge/starter-components/deck/commands.ts @@ -0,0 +1,439 @@ +import type { Block, DeckProject, DeckSlide } from './types'; +import { imageContentOf } from './assets'; +import { migrateLayoutBindings, newId } from './seed'; + +export type Command = + | { type: 'updateBlockContent'; slideId: string; blockId: string; content: unknown } + | { type: 'updateBlockStyle'; slideId: string; blockId: string; style: Record } + | { type: 'updateBlockAlt'; slideId: string; blockId: string; alt: string } + | { type: 'updateImageSource'; slideId: string; blockId: string; src: string; width?: number; height?: number } + | { type: 'updateSlideTitle'; slideId: string; title: string } + | { type: 'updateSlideNotes'; slideId: string; notes: string } + | { type: 'updateSlideLayout'; slideId: string; layout: string } + | { type: 'updateSlideTransition'; slideId: string; transition: string } + | { type: 'addBlock'; slideId: string; block: Block; slot?: string } + | { type: 'removeBlock'; slideId: string; blockId: string } + | { type: 'duplicateBlock'; slideId: string; blockId: string } + | { type: 'setTheme'; themeId: string } + | { type: 'setCanvas'; canvas: DeckProject['canvas'] } + | { type: 'setTransition'; transition: string } + | { type: 'setMotionProfile'; motionProfileId: string } + | { type: 'setReducedMotion'; reducedMotion: 'respect-system' | 'always' | 'never' } + | { type: 'addSlide'; afterIndex?: number } + | { type: 'duplicateSlide'; slideId: string } + | { type: 'removeSlide'; slideId: string } + | { type: 'moveSlide'; fromIndex: number; toIndex: number } + | { type: 'updateMeta'; title?: string; description?: string } + | { type: 'updateBlockAnimation'; slideId: string; blockId: string; animation: Block['animation'] | null } + | { type: 'replaceDeck'; deck: DeckProject }; + +/** + * Command outcome metadata (P0-007). Every mutation reports the IDs it created + * and removed plus the slides it affected, so callers can drive selection, + * repair, and AI provenance without re-deriving state. + */ +export interface DispatchResult { + deck: DeckProject; + createdIds: string[]; + removedIds: string[]; + affectedSlideIds: string[]; +} + +function result(deck: DeckProject): DispatchResult { + return { deck, createdIds: [], removedIds: [], affectedSlideIds: [] }; +} + +function mapSlide(deck: DeckProject, slideId: string, fn: (slide: DeckSlide) => DeckSlide): DeckProject { + return { + ...deck, + slides: deck.slides.map((slide) => (slide.id === slideId ? fn(slide) : slide)), + }; +} + +/** + * Best-effort MIME type for a data: URL; remote URLs are resolved at fetch + * time and reported by the preparation phase. + */ +function mimeTypeOf(src: string): string | undefined { + if (src.startsWith('data:')) { + const mime = src.slice(5).split(';')[0]; + return mime || undefined; + } + return undefined; +} + +function mapBlock( + deck: DeckProject, + slideId: string, + blockId: string, + fn: (block: Block) => Block, +): DeckProject { + return mapSlide(deck, slideId, (slide) => ({ + ...slide, + blocks: slide.blocks.map((block) => (block.id === blockId ? fn(block) : block)), + })); +} + +function addBlockToBinding( + bindings: NonNullable, + slot: string, + blockId: string, +): NonNullable { + const existing = bindings.find((binding) => binding.slot === slot); + if (existing) { + return bindings.map((binding) => + binding.slot === slot ? { ...binding, blockIds: [...binding.blockIds, blockId] } : binding, + ); + } + return [...bindings, { slot, blockIds: [blockId], flow: 'stack', gap: 8 }]; +} + +function newSlideTemplate(title: string): DeckSlide { + const kicker = newId('b'); + const heading = newId('b'); + const body = newId('b'); + return { + id: newId('s'), + title, + layout: 'two-column', + blocks: [ + { id: kicker, type: 'text', content: 'SECTION', style: { variant: 'kicker' }, sourceIds: [], slot: 'kicker', positionMode: 'slot' }, + { id: heading, type: 'heading', content: title, style: { level: 1 }, sourceIds: [], slot: 'title', positionMode: 'slot' }, + { id: body, type: 'text', content: 'Add your content here.', style: {}, sourceIds: [], slot: 'left', positionMode: 'slot' }, + ], + speakerNotes: '', + sources: [], + interactions: [], + density: 'medium', + layoutBindings: [ + { slot: 'kicker', blockIds: [kicker], flow: 'stack', gap: 8 }, + { slot: 'title', blockIds: [heading], flow: 'stack', gap: 8 }, + { slot: 'left', blockIds: [body], flow: 'stack', gap: 8 }, + ], + }; +} + +/** + * The canonical title block is the heading bound to the `title` slot (or the + * first heading block when no title binding exists). This unifies slide + * metadata with the visible heading so the inspector, canvas, and export stay + * in sync (DF-014). + */ +function titleBlockId(slide: DeckSlide): string | undefined { + const titleBinding = (slide.layoutBindings ?? []).find((binding) => binding.slot === 'title'); + const boundId = titleBinding?.blockIds[0]; + const boundBlock = boundId ? slide.blocks.find((block) => block.id === boundId) : undefined; + if (boundBlock?.type === 'heading') return boundBlock.id; + return slide.blocks.find((block) => block.type === 'heading')?.id; +} + +/** + * Apply a command and return both the new deck and its metadata. + * `applyCommand` is a thin wrapper kept for callers that only need the deck. + */ +export function applyCommandWithResult(deck: DeckProject, command: Command): DispatchResult { + switch (command.type) { + case 'updateBlockContent': { + const next = mapSlide(deck, command.slideId, (slide) => { + const blocks = slide.blocks.map((block) => + block.id === command.blockId ? { ...block, content: command.content } : block, + ); + // Keep slide title metadata in sync with the visible heading (DF-014). + if (command.blockId === titleBlockId(slide)) { + return { ...slide, title: typeof command.content === 'string' ? command.content : slide.title, blocks }; + } + return { ...slide, blocks }; + }); + return { ...result(next), affectedSlideIds: [command.slideId] }; + } + case 'updateBlockStyle': + return { + ...result(mapBlock(deck, command.slideId, command.blockId, (block) => ({ ...block, style: { ...block.style, ...command.style } }))), + affectedSlideIds: [command.slideId], + }; + case 'updateBlockAlt': + return { + ...result(mapBlock(deck, command.slideId, command.blockId, (block) => ({ ...block, alt: command.alt }))), + affectedSlideIds: [command.slideId], + }; + case 'updateImageSource': { + // Atomic image-source edit: the block's manifest binding and the asset + // manifest stay consistent in ONE command. Previously the inspector wrote + // content.src only, leaving the manifest stale so preflight and the PPTX + // exporter could disagree about whether the image resolves (P2-004). + const trimmed = command.src.trim(); + const created: string[] = []; + let nextAssets = deck.assets ?? []; + + // The upload path knows the embedded pixel dimensions; keep them on the + // manifest entry so exporters can crop cover/contain from the real + // aspect ratio instead of stretching the frame. + const hasDims = + typeof command.width === 'number' && + command.width > 0 && + typeof command.height === 'number' && + command.height > 0; + const dims = hasDims ? { width: command.width, height: command.height } : {}; + + const next = mapSlide(deck, command.slideId, (slide) => ({ + ...slide, + blocks: slide.blocks.map((block) => { + if (block.id !== command.blockId || block.type !== 'image') return block; + const content = imageContentOf(block); + const existingAssetId = content.assetId; + const existingAsset = existingAssetId + ? nextAssets.find((a) => a.id === existingAssetId) + : undefined; + + if (!trimmed) { + // Clearing the URL unbinds the block so it renders as the designed + // placeholder (export: rasterized placeholder, never an error). + return { + ...block, + content: { ...content, src: undefined, assetId: undefined }, + }; + } + + let assetId = existingAsset ? existingAsset.id : (existingAssetId ?? ''); + if (!assetId) { + assetId = newId('asset'); + created.push(assetId); + } + + if (nextAssets.some((a) => a.id === assetId)) { + nextAssets = nextAssets.map((a) => + a.id === assetId + ? { + ...a, + src: trimmed, + mimeType: a.mimeType ?? mimeTypeOf(trimmed), + // A source replaced without known dimensions (e.g. a pasted + // URL) must not keep the previous image's aspect ratio. + ...(hasDims ? dims : { width: undefined, height: undefined }), + } + : a, + ); + } else { + nextAssets = [ + ...nextAssets, + { id: assetId, kind: 'image' as const, src: trimmed, mimeType: mimeTypeOf(trimmed), ...dims }, + ]; + } + + // The manifest owns the source; the block just binds to it. + return { + ...block, + content: { ...content, src: undefined, assetId }, + }; + }), + })); + + return { + deck: { ...next, assets: nextAssets }, + createdIds: created, + removedIds: [], + affectedSlideIds: [command.slideId], + }; + } + case 'updateSlideTitle': + return { + ...result(mapSlide(deck, command.slideId, (slide) => { + const titleId = titleBlockId(slide); + const blocks = titleId + ? slide.blocks.map((block) => (block.id === titleId ? { ...block, content: command.title } : block)) + : slide.blocks; + return { ...slide, title: command.title, blocks }; + })), + affectedSlideIds: [command.slideId], + }; + case 'updateSlideNotes': + return { + ...result(mapSlide(deck, command.slideId, (slide) => ({ ...slide, speakerNotes: command.notes }))), + affectedSlideIds: [command.slideId], + }; + case 'updateSlideLayout': + return { + ...result(mapSlide(deck, command.slideId, (slide) => migrateLayoutBindings(slide, command.layout))), + affectedSlideIds: [command.slideId], + }; + case 'updateSlideTransition': + return { + ...result(mapSlide(deck, command.slideId, (slide) => ({ ...slide, transition: command.transition }))), + affectedSlideIds: [command.slideId], + }; + case 'addBlock': { + const block = command.slot ? { ...command.block, slot: command.slot } : command.block; + const next = mapSlide(deck, command.slideId, (slide) => ({ + ...slide, + blocks: [...slide.blocks, block], + layoutBindings: command.slot + ? addBlockToBinding(slide.layoutBindings ?? [], command.slot, block.id) + : slide.layoutBindings, + })); + return { + ...result(next), + createdIds: [block.id], + affectedSlideIds: [command.slideId], + }; + } + case 'removeBlock': { + const next = mapSlide(deck, command.slideId, (slide) => ({ + ...slide, + blocks: slide.blocks.filter((block) => block.id !== command.blockId), + layoutBindings: (slide.layoutBindings ?? []).map((binding) => ({ + ...binding, + blockIds: binding.blockIds.filter((id) => id !== command.blockId), + })), + focalBlockId: slide.focalBlockId === command.blockId ? undefined : slide.focalBlockId, + })); + return { + ...result(next), + removedIds: [command.blockId], + affectedSlideIds: [command.slideId], + }; + } + case 'duplicateBlock': { + let createdId = ''; + const next = mapSlide(deck, command.slideId, (slide) => { + const source = slide.blocks.find((block) => block.id === command.blockId); + if (!source) return slide; + const copy: Block = { ...structuredClone(source), id: newId('b'), slot: source.slot, positionMode: source.slot ? 'slot' : source.positionMode }; + createdId = copy.id; + return { + ...slide, + blocks: [...slide.blocks, copy], + layoutBindings: (slide.layoutBindings ?? []).map((binding) => + binding.blockIds.includes(command.blockId) + ? { ...binding, blockIds: [...binding.blockIds, copy.id] } + : binding, + ), + }; + }); + return { + ...result(next), + createdIds: createdId ? [createdId] : [], + affectedSlideIds: [command.slideId], + }; + } + case 'setTheme': + return result({ ...deck, theme: { ...deck.theme, id: command.themeId } }); + case 'setCanvas': { + const prev = deck.canvas; + const next = command.canvas; + const scaleX = next.width / prev.width; + const scaleY = next.height / prev.height; + const needsRescale = scaleX !== 1 || scaleY !== 1; + if (!needsRescale) return result({ ...deck, canvas: next }); + const rescaled: DeckProject = { + ...deck, + canvas: next, + slides: deck.slides.map((slide) => ({ + ...slide, + blocks: slide.blocks.map((block) => { + if (!block.frame) return block; + const f = block.frame; + return { + ...block, + frame: { + x: Math.round(f.x * scaleX), + y: Math.round(f.y * scaleY), + w: Math.round(f.w * scaleX), + h: Math.round(f.h * scaleY), + }, + }; + }), + })), + }; + return { ...result(rescaled), affectedSlideIds: deck.slides.map((s) => s.id) }; + } + case 'setTransition': + return result({ ...deck, presentation: { ...deck.presentation, transition: command.transition } }); + case 'setMotionProfile': + return result({ ...deck, presentation: { ...deck.presentation, motionProfileId: command.motionProfileId } }); + case 'setReducedMotion': + return result({ ...deck, presentation: { ...deck.presentation, reducedMotion: command.reducedMotion } }); + case 'addSlide': { + const afterIndex = command.afterIndex ?? deck.slides.length - 1; + const nextSlide = newSlideTemplate('Untitled slide'); + const slides = [ + ...deck.slides.slice(0, afterIndex + 1), + nextSlide, + ...deck.slides.slice(afterIndex + 1), + ]; + return { + deck: { ...deck, slides }, + createdIds: [nextSlide.id], + removedIds: [], + affectedSlideIds: [nextSlide.id], + }; + } + case 'duplicateSlide': { + const sourceIndex = deck.slides.findIndex((slide) => slide.id === command.slideId); + if (sourceIndex < 0) return result(deck); + const copy: DeckSlide = structuredClone(deck.slides[sourceIndex]); + copy.id = newId('s'); + copy.title = `${copy.title} (copy)`; + const remap = new Map(); + for (const block of copy.blocks) { + const old = block.id; + block.id = newId('b'); + remap.set(old, block.id); + } + copy.layoutBindings = (copy.layoutBindings ?? []).map((binding) => ({ + ...binding, + blockIds: binding.blockIds.map((id) => remap.get(id) ?? id), + })); + copy.focalBlockId = copy.focalBlockId ? remap.get(copy.focalBlockId) : undefined; + const slides = [...deck.slides]; + slides.splice(sourceIndex + 1, 0, copy); + return { + deck: { ...deck, slides }, + createdIds: [copy.id, ...copy.blocks.map((block) => block.id)], + removedIds: [], + affectedSlideIds: [copy.id], + }; + } + case 'removeSlide': { + if (deck.slides.length <= 1) return result(deck); + return { + deck: { ...deck, slides: deck.slides.filter((slide) => slide.id !== command.slideId) }, + createdIds: [], + removedIds: [command.slideId], + affectedSlideIds: deck.slides + .filter((slide) => slide.id !== command.slideId) + .map((slide) => slide.id), + }; + } + case 'moveSlide': { + const slides = [...deck.slides]; + const [moved] = slides.splice(command.fromIndex, 1); + if (!moved) return result(deck); + slides.splice(command.toIndex, 0, moved); + return { deck: { ...deck, slides }, createdIds: [], removedIds: [], affectedSlideIds: [moved.id] }; + } + case 'updateMeta': + return result({ + ...deck, + meta: { ...deck.meta, ...(command.title != null ? { title: command.title } : {}), ...(command.description != null ? { description: command.description } : {}) }, + }); + case 'updateBlockAnimation': + return { + ...result(mapBlock(deck, command.slideId, command.blockId, (block) => { + if (command.animation === null) { + const { animation: _removed, ...rest } = block; + return rest as Block; + } + return { ...block, animation: command.animation }; + })), + affectedSlideIds: [command.slideId], + }; + case 'replaceDeck': + return { deck: command.deck, createdIds: [], removedIds: [], affectedSlideIds: [] }; + default: + return result(deck); + } +} + +export function applyCommand(deck: DeckProject, command: Command): DeckProject { + return applyCommandWithResult(deck, command).deck; +} diff --git a/skills/deckforge/starter-components/deck/seed.ts b/skills/deckforge/starter-components/deck/seed.ts new file mode 100644 index 0000000..6178767 --- /dev/null +++ b/skills/deckforge/starter-components/deck/seed.ts @@ -0,0 +1,279 @@ +import type { Block, DeckProject, DeckSlide } from './types'; +import { getLayoutContract, suggestSlotForBlock, type LayoutSlotContract } from './layout'; + + +export function newId(prefix = 'b'): string { + return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +export function makeTextBlock(id: string, content: string, slot: string): Block { + return { + id, + type: 'text', + content, + style: {}, + sourceIds: [], + slot, + positionMode: 'slot', + }; +} + +export function makeHeadingBlock(id: string, content: string, slot: string, level = 3): Block { + return { + id, + type: 'heading', + content, + style: { level }, + sourceIds: [], + slot, + positionMode: 'slot', + }; +} + +/** Rebind blocks from a legacy layout to a new layout by best-effort slot mapping. */ +const SLOT_ALIASES: Record = { + title: ['title'], + kicker: ['kicker', 'context', 'chapter'], + subtitle: ['subtitle', 'support', 'interpretation', 'meaning'], + visual: ['visual', 'chart', 'map', 'devices', 'gallery', 'demo', 'accent'], + 'option-a': ['option-a', 'before', 'left', 'column-1'], + 'option-b': ['option-b', 'after', 'right', 'column-2'], + decision: ['decision', 'impact', 'takeaway', 'outcome'], + steps: ['steps', 'timeline', 'process'], + footer: ['footer', 'contact', 'source', 'caption', 'meta'], +}; + +const SLOT_ALIAS_REVERSE: Record = Object.fromEntries( + Object.entries(SLOT_ALIASES).flatMap(([target, aliases]) => aliases.map((alias) => [alias, target])), +); + +/** Generic fallback slots that accept most block types when no better match exists. */ +const GENERIC_SLOTS = ['left', 'right', 'column-1', 'column-2', 'column-3', 'support', 'content', 'body']; + +function slotAccepts(slot: LayoutSlotContract, type: string): boolean { + return !slot.allowedBlocks?.length || slot.allowedBlocks.includes(type); +} + +/** + * Lossless layout migration (P0-003, DF-012). + * + * Rebinds every non-background, non-freeform block to a slot in the target + * layout so no block disappears when a layout changes. Mapping priority: + * + * 1. The block's current slot, when it exists in the target layout and accepts + * the block type. + * 2. A semantic alias slot in the target layout that accepts the block type. + * 3. A generic slot in the target layout that accepts the block type. + * 4. Any target slot with remaining capacity (never drops a block). + * + * Freeform and background blocks are left untouched: they live on their own + * rendering layer and are not bound to slots. + */ +export function migrateLayoutBindings( + slide: DeckSlide, + newLayout: string, +): DeckSlide { + const contract = getLayoutContract(newLayout); + if (!contract?.composition?.slots.length) { + return { ...slide, layout: newLayout, layoutBindings: slide.layoutBindings }; + } + + const slots = contract.composition.slots; + const responsiveOrder = contract.composition.responsiveOrder ?? slots.map((slot) => slot.id); + const slotById = new Map(slots.map((slot) => [slot.id, slot])); + const slotCapacity = new Map(); + for (const slot of slots) { + slotCapacity.set(slot.id, slot.maxItems ?? Number.POSITIVE_INFINITY); + } + + const targetIds = new Set(slots.map((slot) => slot.id)); + const slotBlocks = slide.blocks.filter((block) => block.positionMode !== 'background' && block.positionMode !== 'freeform'); + const boundCounts = new Map(); + const assigned = new Set(); + const bindingMap = new Map(); + + const place = (blockId: string, slotId: string) => { + const used = boundCounts.get(slotId) ?? 0; + if (used >= slotCapacity.get(slotId)!) return false; + const list = bindingMap.get(slotId) ?? []; + bindingMap.set(slotId, [...list, blockId]); + boundCounts.set(slotId, used + 1); + assigned.add(blockId); + return true; + }; + + /** Place without a capacity check; used only as a last resort to avoid data loss. */ + const placeUnchecked = (slotId: string, blockId: string) => { + const list = bindingMap.get(slotId) ?? []; + bindingMap.set(slotId, [...list, blockId]); + boundCounts.set(slotId, (boundCounts.get(slotId) ?? 0) + 1); + assigned.add(blockId); + return true; + }; + + const candidateSlotsFor = (block: Block): string[] => { + const current = block.slot; + const candidates = new Set(); + if (current && targetIds.has(current)) candidates.add(current); + const alias = current ? SLOT_ALIAS_REVERSE[current] : undefined; + if (alias && targetIds.has(alias)) candidates.add(alias); + for (const generic of GENERIC_SLOTS) { + if (targetIds.has(generic)) candidates.add(generic); + } + // Fallback: any target slot (keep deterministic order, required slots first). + const ordered = [...slots].sort((a, b) => Number(Boolean(b.required)) - Number(Boolean(a.required))); + for (const slot of ordered) candidates.add(slot.id); + return [...candidates]; + }; + + for (const block of slotBlocks) { + if (assigned.has(block.id)) continue; + const candidates = candidateSlotsFor(block); + const chosen = candidates.find((slotId) => slotAccepts(slotById.get(slotId)!, block.type) && place(block.id, slotId)); + if (!chosen) { + // Last resort: bind to any slot that accepts the block type even when at + // capacity (soft overflow is a warning, not data loss), then to any slot + // regardless of allowedBlocks so a block never silently disappears. + const accepting = [...slots].find((slot) => slotAccepts(slot, block.type) && placeUnchecked(slot.id, block.id)); + if (!accepting) { + for (const slot of slots) { + if (placeUnchecked(slot.id, block.id)) break; + } + } + } + } + + const layoutBindings = responsiveOrder + .filter((slotId) => bindingMap.has(slotId)) + .map((slotId) => ({ + slot: slotId, + blockIds: bindingMap.get(slotId)!, + flow: 'stack' as const, + gap: 10, + })); + + return { + ...slide, + layout: newLayout, + layoutBindings, + }; +} + +/** + * Legacy block migration (P0-003, DF-012). + * + * Repairs stale blocks that have positionMode "slot" but: + * - No slot property (MISSING_SLOT_ID) + * - A slot that doesn't exist in the layout (UNKNOWN_SLOT) + * - A slot that doesn't accept the block type (SLOT_TYPE_MISMATCH) + * + * This migration runs automatically when loading legacy documents + * to ensure all blocks are exportable without manual repair. + * + * Returns a NEW slide with repaired blocks and bindings. + * The input slide is never mutated. + */ +export function migrateLegacyBlockSlots(slide: DeckSlide): DeckSlide { + const contract = getLayoutContract(slide.layout); + if (!contract?.composition?.slots.length) return slide; + + const slots = contract.composition.slots; + const slotById = new Map(slots.map((slot) => [slot.id, slot])); + const slotCapacity = new Map(); + for (const slot of slots) { + slotCapacity.set(slot.id, slot.maxItems ?? Number.POSITIVE_INFINITY); + } + + // Build current bindings map + const bindingMap = new Map(); + for (const binding of slide.layoutBindings ?? []) { + bindingMap.set(binding.slot, [...binding.blockIds]); + } + + // Track which blocks are bound + const boundCounts = new Map(); + for (const [slotId, ids] of bindingMap) { + boundCounts.set(slotId, ids.length); + } + + const needsRepair: Block[] = []; + const repairedBlocks: Block[] = []; + + for (const block of slide.blocks) { + if (block.hidden) continue; + if (block.positionMode === 'freeform' || block.positionMode === 'background') { + repairedBlocks.push(block); + continue; + } + + // Check if block needs repair + const slotContract = block.slot ? slotById.get(block.slot) : undefined; + const needsSlotRepair = !block.slot || !slotContract || !slotAccepts(slotContract, block.type); + + if (needsSlotRepair) { + needsRepair.push(block); + } else { + repairedBlocks.push(block); + } + } + + if (needsRepair.length === 0) return slide; + + // Repair each block using the same logic as suggestSlotForBlock + for (const block of needsRepair) { + const slideWithBlock: DeckSlide = { + ...slide, + blocks: [...slide.blocks, block], + }; + const suggestedSlot = suggestSlotForBlock(slideWithBlock, block); + + if (suggestedSlot) { + // Add block to the suggested slot's binding + if (!bindingMap.has(suggestedSlot)) { + bindingMap.set(suggestedSlot, []); + } + bindingMap.get(suggestedSlot)!.push(block.id); + boundCounts.set(suggestedSlot, (boundCounts.get(suggestedSlot) ?? 0) + 1); + + // Add repaired block + repairedBlocks.push({ + ...block, + slot: suggestedSlot, + positionMode: 'slot', + }); + } else { + // No slot found — keep block as-is (will be caught by geometry resolver) + repairedBlocks.push(block); + } + } + + // Build new layoutBindings + const responsiveOrder = contract.composition.responsiveOrder ?? slots.map((s) => s.id); + const layoutBindings = responsiveOrder + .filter((slotId) => bindingMap.has(slotId)) + .map((slotId) => ({ + slot: slotId, + blockIds: bindingMap.get(slotId)!, + flow: 'stack' as const, + gap: 10, + })); + + return { + ...slide, + blocks: repairedBlocks, + layoutBindings, + }; +} + +/** + * Migrate all legacy blocks in a deck. + * + * Returns a NEW deck with repaired blocks and bindings. + * The input deck is never mutated. + */ +export function migrateLegacyDeckSlots(deck: DeckProject): DeckProject { + return { + ...deck, + slides: deck.slides.map((slide) => migrateLegacyBlockSlots(slide)), + }; +} From 74dd8f4ecfda5d5db545ee017bbefe93f8ac6013 Mon Sep 17 00:00:00 2001 From: tph-kds Date: Sat, 15 Aug 2026 03:46:01 +0700 Subject: [PATCH 03/16] feat(scaffold): port 12 export modules from 02-example --- .../starter-components/export/export-scene.ts | 156 +++++ .../export/fidelity/svg/svg-chart.ts | 233 +++++++ .../export/fidelity/svg/svg-raster.ts | 45 ++ .../starter-components/export/geometry.ts | 194 ++++++ .../export/image-dimensions.ts | 167 +++++ .../export/pptx/block-exporters/process.ts | 236 +++++++ .../export/pptx/export-utils.ts | 159 +++++ .../export/pptx/pptx-placeholder.ts | 17 + .../export/prepare-export.ts | 181 ++++++ .../export/resolved-theme.ts | 253 ++++++++ .../export/self-contained.ts | 104 +++ .../starter-components/export/snapshot.ts | 595 ++++++++++++++++++ 12 files changed, 2340 insertions(+) create mode 100644 skills/deckforge/starter-components/export/export-scene.ts create mode 100644 skills/deckforge/starter-components/export/fidelity/svg/svg-chart.ts create mode 100644 skills/deckforge/starter-components/export/fidelity/svg/svg-raster.ts create mode 100644 skills/deckforge/starter-components/export/geometry.ts create mode 100644 skills/deckforge/starter-components/export/image-dimensions.ts create mode 100644 skills/deckforge/starter-components/export/pptx/block-exporters/process.ts create mode 100644 skills/deckforge/starter-components/export/pptx/export-utils.ts create mode 100644 skills/deckforge/starter-components/export/pptx/pptx-placeholder.ts create mode 100644 skills/deckforge/starter-components/export/prepare-export.ts create mode 100644 skills/deckforge/starter-components/export/resolved-theme.ts create mode 100644 skills/deckforge/starter-components/export/self-contained.ts create mode 100644 skills/deckforge/starter-components/export/snapshot.ts diff --git a/skills/deckforge/starter-components/export/export-scene.ts b/skills/deckforge/starter-components/export/export-scene.ts new file mode 100644 index 0000000..6f712d8 --- /dev/null +++ b/skills/deckforge/starter-components/export/export-scene.ts @@ -0,0 +1,156 @@ +// export/export-scene.ts +// +// Structural validation of a fully-resolved export scene (Phase 16): +// catches the kinds of corruption that previously produced silent (0,0) +// geometry or leaked placeholder content into the deck file. Pure and +// framework-free so it can run in tests and CI. + +import type { PptxExportContext, PptxSlideElement, ExportIssueCode } from "./export-types"; +import { + aspectMatches, + validateRectWithinSlide, + validateFrame, +} from "./geometry"; + +export type SceneSeverity = "error" | "warning"; + +export interface ExportSceneDiagnostic { + code: ExportIssueCode; + severity: SceneSeverity; + slideId?: string; + elementId?: string; + message: string; +} + +export interface ExportScene { + slides: Array<{ slideId: string; elements: PptxSlideElement[] }>; +} + +/** + * The "New chart" template is not detected by its title (never special-case + * titles). Template charts are instead excluded at the source: chart blocks + * with `isTemplate: true` never reach the exported element list, and the + * exporter enforces "every exported chart maps to a visible source block". + */ + +/** + * Detect fallback elements that stand in for failed images. + */ +function detectUnresolvedImages(slide: ExportScene["slides"][number]): ExportSceneDiagnostic[] { + return slide.elements + .filter((element) => element.type === "fallback") + .map((element): ExportSceneDiagnostic[] => { + const text = (element.data as { text?: string }).text ?? ""; + if (/image unavailable/i.test(text)) { + return [ + { + code: "unresolved-image", + severity: "warning", + slideId: slide.slideId, + elementId: element.elementId, + message: `Slide "${slide.slideId}" contains an image that could not be resolved (element "${element.elementId}"); it was replaced with a placeholder`, + }, + ]; + } + return []; + }) + .flat(); +} + +/** Detect malformed or missing element geometry. */ +function detectGeometryErrors( + slide: ExportScene["slides"][number], + ctx: PptxExportContext, +): ExportSceneDiagnostic[] { + return slide.elements + .map((element): ExportSceneDiagnostic[] => { + const frame = { x: element.x, y: element.y, w: element.w, h: element.h }; + const errors = validateRectWithinSlide(frame, ctx.slideWidth, ctx.slideHeight); + if (errors.length) { + return [ + { + code: "invalid-geometry", + severity: "error", + slideId: slide.slideId, + elementId: element.elementId, + message: `Slide "${slide.slideId}" element "${element.elementId}" has invalid geometry: ${errors.join("; ")}`, + }, + ]; + } + return []; + }) + .flat(); +} + +/** Detect duplicate element ids across the whole deck (corrupt file risk). */ +function detectDuplicateElementIds( + slides: ExportScene["slides"], +): ExportSceneDiagnostic[] { + const seen = new Map(); + const diagnostics: ExportSceneDiagnostic[] = []; + for (const slide of slides) { + for (const element of slide.elements) { + const id = element.elementId; + if (!id) continue; + const existingSlide = seen.get(id); + if (existingSlide !== undefined && existingSlide !== slide.slideId) { + diagnostics.push({ + code: "duplicate-element-id", + severity: "warning", + slideId: slide.slideId, + elementId: id, + message: `Element id "${id}" appears on both slide "${existingSlide}" and "${slide.slideId}"; duplicate ids can break edit targeting`, + }); + } else { + seen.set(id, slide.slideId); + } + } + } + return diagnostics; +} + +/** Detect the PPTX/web aspect mismatch that hard-coded 13.333"x7.5" caused. */ +function detectAspectMismatch(ctx: PptxExportContext): ExportSceneDiagnostic[] { + const matches = aspectMatches( + ctx.slideWidth, + ctx.slideHeight, + ctx.pptxWidth, + ctx.pptxHeight, + ); + if (!matches) { + return [ + { + code: "aspect-mismatch", + severity: "error", + message: `PPTX slide size (${ctx.pptxWidth}"x${ctx.pptxHeight}") does not match the document canvas aspect ratio (${ctx.slideWidth}x${ctx.slideHeight}px); exports will be distorted`, + }, + ]; + } + return []; +} + +/** + * Validate a fully-resolved export scene. Returns a list of diagnostics + * grouped by severity; the export pipeline surfaces errors as failed status. + */ +export function validateExportScene( + scene: ExportScene, + ctx: PptxExportContext, +): ExportSceneDiagnostic[] { + const diagnostics: ExportSceneDiagnostic[] = [ + ...detectAspectMismatch(ctx), + ...detectDuplicateElementIds(scene.slides), + ]; + for (const slide of scene.slides) { + diagnostics.push(...detectGeometryErrors(slide, ctx)); + diagnostics.push(...detectUnresolvedImages(slide)); + } + return diagnostics; +} + +/** True when a scene has at least one error-severity diagnostic. */ +export function sceneHasErrors(diagnostics: ExportSceneDiagnostic[]): boolean { + return diagnostics.some((diagnostic) => diagnostic.severity === "error"); +} + +export { validateFrame }; \ No newline at end of file diff --git a/skills/deckforge/starter-components/export/fidelity/svg/svg-chart.ts b/skills/deckforge/starter-components/export/fidelity/svg/svg-chart.ts new file mode 100644 index 0000000..80ea541 --- /dev/null +++ b/skills/deckforge/starter-components/export/fidelity/svg/svg-chart.ts @@ -0,0 +1,233 @@ +import type { ResolvedChartSpec } from "../../snapshot"; + +function escapeXml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function formatValue(value: number, unit: string): string { + return `${value}${unit}`; +} + +// ─── Vertical Bar Chart ────────────────────────────────────────────────────── + +interface ChartPlotRect { + x: number; + y: number; + w: number; + h: number; +} + +interface ChartGridline { + fraction: number; + y: number; + label: string; + labelX: number; + labelY: number; +} + +interface BarPlacement { + barX: number; + barY: number; + barW: number; + barH: number; + dataLabelX: number; + dataLabelY: number; + categoryLabelX: number; + categoryLabelY: number; + dataLabel: string; +} + +interface BarChartLayout { + plot: ChartPlotRect; + gridlines: ChartGridline[]; + bars: BarPlacement[]; + axisLabelAnchor: "end" | "start"; +} + +function describeBarChartLayout(spec: ResolvedChartSpec): BarChartLayout { + const width = 560; + const height = 300; + const values = spec.series[0]?.values ?? []; + const categories = spec.categories; + const titleH = spec.title ? 20 : 0; + const padX = 8; + const categoryH = 18; + const baselineH = 8; + const axisW = 46; + const dataLabelPadding = 18; + const showDataLabels = true; + const dataPosition = "out-end"; + const dataPad = showDataLabels && dataPosition === "out-end" ? dataLabelPadding : 0; + + const axisOnLeft = true; + const plot: ChartPlotRect = { + x: padX + (axisOnLeft ? axisW : 0), + y: titleH + dataPad, + w: width - padX * 2 - axisW, + h: height - titleH - dataPad - baselineH - categoryH, + }; + + const maxValue = values.length ? Math.max(...values, 1) : 1; + const slotW = values.length ? plot.w / values.length : plot.w; + const barW = Math.min(slotW * 0.55, 42); + + const fractions = [0, 0.25, 0.5, 0.75, 1]; + const gridlines: ChartGridline[] = fractions.map((fraction) => { + const y = plot.y + plot.h - fraction * plot.h; + const raw = Math.round(fraction * maxValue * 10) / 10; + const label = String(raw); + return { + fraction, + y, + label, + labelX: axisOnLeft ? plot.x - 4 : plot.x + plot.w + 4, + labelY: y - 4, + }; + }); + + const bars: BarPlacement[] = values.map((value, index) => { + const h = (value / maxValue) * plot.h; + const x = plot.x + index * slotW + (slotW - barW) / 2; + const y = plot.y + plot.h - h; + return { + barX: x, + barY: y, + barW, + barH: Math.max(h, 2), + dataLabelX: x + barW / 2, + dataLabelY: y - 4, + categoryLabelX: x + barW / 2, + categoryLabelY: plot.y + plot.h + baselineH + categoryH - 5, + dataLabel: formatValue(value, spec.unit), + }; + }); + + return { + plot, + gridlines, + bars, + axisLabelAnchor: axisOnLeft ? "end" : "start", + }; +} + +function renderVerticalBar(spec: ResolvedChartSpec): string { + const layout = describeBarChartLayout(spec); + const { plot, gridlines, bars } = layout; + const values = spec.series[0]?.values ?? []; + const style = spec.style; + const fontFamily = escapeXml(style.fontFamily); + + const parts: string[] = []; + + // Title + if (spec.title) { + parts.push( + `${escapeXml(spec.title)}` + ); + } + + // Baseline + parts.push( + `` + ); + + // Gridlines + axis labels + for (const gridline of gridlines) { + const dashArray = gridline.fraction === 0 ? "none" : "3 4"; + parts.push( + `` + ); + parts.push( + `${escapeXml(gridline.label)}` + ); + } + + // Bars + labels + for (let index = 0; index < values.length; index++) { + const placement = bars[index]; + const isHighlight = spec.highlightIndex === index; + const fill = style.seriesColors[index] ?? style.accentColor; + const label = spec.categories[index] ?? ""; + + parts.push( + `` + ); + parts.push( + `${escapeXml(label)}` + ); + parts.push( + `${escapeXml(placement.dataLabel)}` + ); + } + + return parts.join("\n"); +} + +// ─── Horizontal Bar Chart ──────────────────────────────────────────────────── + +function renderHorizontalBar(spec: ResolvedChartSpec): string { + const values = spec.series[0]?.values ?? []; + const categories = spec.categories; + const style = spec.style; + const fontFamily = escapeXml(style.fontFamily); + const max = Math.max(...values, 1); + const titleH = spec.title ? 20 : 0; + const rowH = 40; + const labelW = 96; + const barMaxW = 560 - labelW - 56; + const top = titleH + 8; + + const parts: string[] = []; + + // Title + if (spec.title) { + parts.push( + `${escapeXml(spec.title)}` + ); + } + + // Rows + for (let index = 0; index < values.length; index++) { + const value = values[index]; + const y = top + index * rowH; + const w = (value / max) * barMaxW; + const isHighlight = spec.highlightIndex === index; + const fill = style.seriesColors[index] ?? style.accentColor; + const label = categories[index] ?? ""; + + parts.push( + `${escapeXml(label)}` + ); + parts.push( + `` + ); + parts.push( + `${escapeXml(formatValue(value, spec.unit))}` + ); + } + + return parts.join("\n"); +} + +// ─── Public API ────────────────────────────────────────────────────────────── + +export function renderChartToSvg(spec: ResolvedChartSpec): string { + const parts: string[] = []; + parts.push( + `` + ); + + if (spec.orientation === "horizontal") { + parts.push(renderHorizontalBar(spec)); + } else { + parts.push(renderVerticalBar(spec)); + } + + parts.push(""); + return parts.join("\n"); +} diff --git a/skills/deckforge/starter-components/export/fidelity/svg/svg-raster.ts b/skills/deckforge/starter-components/export/fidelity/svg/svg-raster.ts new file mode 100644 index 0000000..1627e16 --- /dev/null +++ b/skills/deckforge/starter-components/export/fidelity/svg/svg-raster.ts @@ -0,0 +1,45 @@ +// export/fidelity/svg/svg-raster.ts +// +// Rasterize an SVG string to a PNG buffer in pure Node. PptxGenJS embeds SVG +// images by generating a PNG *preview* in a browser `Image`/`canvas`, which is +// not available in Node, so any SVG fallback (charts, diagrams, video posters) +// is rasterized here instead and embedded as a crisp 2x PNG. +// +// Web-only theme fonts that are not installed on office machines are mapped to +// their export substitution before rendering so chart text is deterministic +// (the same mapping the PPTX text exporter uses). + +import { Resvg } from "@resvg/resvg-js"; + +/** Web font -> commonly installed office font, matching pptFontFor(). */ +const FONT_FALLBACK: Record = { + Inter: "Arial", + "Libre Baskerville": "Georgia", + "JetBrains Mono": "Consolas", +}; + +function mapFontFamily(svg: string): string { + let out = svg; + for (const [from, to] of Object.entries(FONT_FALLBACK)) { + out = out.split(`font-family="${from}"`).join(`font-family="${to}"`); + } + return out; +} + +/** + * Render an SVG string to a PNG buffer at the given target width (px). Height + * follows the SVG's intrinsic aspect ratio (viewBox), so callers should only + * rasterize SVGs whose element keeps that aspect (charts: contained 560:300 + * box; diagrams: SVG already sized to the frame). + */ +export function renderSvgToPng(svg: string, pixelWidth: number): Buffer { + const resvg = new Resvg(mapFontFamily(svg), { + fitTo: { mode: "width", value: Math.max(1, Math.round(pixelWidth)) }, + background: "transparent", + }); + const rendered = resvg.render(); + if (!rendered || rendered.width === 0 || rendered.height === 0) { + throw new Error("SVG rasterization produced an empty image"); + } + return rendered.asPng(); +} diff --git a/skills/deckforge/starter-components/export/geometry.ts b/skills/deckforge/starter-components/export/geometry.ts new file mode 100644 index 0000000..c83f279 --- /dev/null +++ b/skills/deckforge/starter-components/export/geometry.ts @@ -0,0 +1,194 @@ +/** + * export/geometry.ts + * + * THE canonical slide-coordinate geometry layer (DeckForge architecture rule). + * + * SlideDocument owns logical width/height + element geometry in DOCUMENT + * pixels. Everything else — editor zoom, pan, Fit, fullscreen, presenter + * letterboxing, and the PPTX surface — is a VIEW or SERIALIZATION transform + * and MUST NOT mutate document geometry. + * + * All pixel space exists in document coordinates. Conversions to PowerPoint + * units are pure, ratio-based, aspect-preserving functions centralised here so + * no individual exporter can invent its own coordinate mapping. + * + * Invariant: docW / docH === pptxW / pptxH + */ + +export interface Size { + width: number; + height: number; +} + +export interface Rect { + x: number; + y: number; + w: number; + h: number; +} + +/** Aspect ratio width/height. */ +export function aspectOf(width: number, height: number): number { + if (!isFinite(width) || !isFinite(height) || height <= 0) return NaN; + return width / height; +} + +/** True when two rectangles have the same aspect ratio within tolerance. */ +export function aspectMatches( + w1: number, + h1: number, + w2: number, + h2: number, + tolerance = 1e-6, +): boolean { + const a = aspectOf(w1, h1); + const b = aspectOf(w2, h2); + if (!isFinite(a) || !isFinite(b)) return false; + return Math.abs(a - b) <= tolerance; +} + +/** + * Derive a PowerPoint slide size (inches) that PRESERVES the document aspect + * ratio for any canvas resolution. `pxPerInch` fixes the physical density but + * never the aspect ratio: PPTX width is proportional to document pixel width + * and height follows from the aspect, so element geometry stays visually + * equivalent whether the canvas is 1600x900, 1920x1080, or 1920x800. + * + * Previously the exporter forced 13.333"x7.5" whenever the canvas was labelled + * "16:9", which silently distorted any canvas whose real pixels were not + * exactly 16:9. This function is the single source of truth instead. + */ +export function derivePptxSlideSize( + documentWidthPx: number, + documentHeightPx: number, + pxPerInch = 120, +): Size { + const safeW = sanitizeDimension(documentWidthPx, 1600); + const safeH = sanitizeDimension(documentHeightPx, 900); + const width = safeW / pxPerInch; + const height = safeH / pxPerInch; + return { width, height }; +} + +/** + * Map a document-coordinate rect into a PPTX rect (inches) by pure ratios + * (Phase 5). When aspect ratios match this is visually equivalent to the + * browser layout. Never falls back to (0,0): use validateFrame first. + */ +export function documentRectToPptxRect( + source: Pick, + documentWidthPx: number, + documentHeightPx: number, + pptxWidthInches: number, + pptxHeightInches: number, +): Rect { + const xRatio = source.x / documentWidthPx; + const yRatio = source.y / documentHeightPx; + const wRatio = source.w / documentWidthPx; + const hRatio = source.h / documentHeightPx; + return { + x: xRatio * pptxWidthInches, + y: yRatio * pptxHeightInches, + w: wRatio * pptxWidthInches, + h: hRatio * pptxHeightInches, + }; +} + +/** + * Convert one document-pixel dimension to PPTX inches, proportional to the + * owning axis of the slide (deterministic; Phase 5/7). + */ +export function documentUnitToPptxInches( + px: number, + documentDimensionPx: number, + pptxDimensionInches: number, +): number { + if (!isFinite(documentDimensionPx) || documentDimensionPx <= 0) return 0; + return (px / documentDimensionPx) * pptxDimensionInches; +} + +/** + * Convert a browser (document-pixel) font size to PowerPoint points so that + * text scales with the slide (Phase 7). At matching aspect ratios this keeps + * the type exactly proportional to the browser layout instead of using + * unrelated hard-coded PPT sizes. + */ +export function browserFontSizeToPptPt( + fontSizePx: number, + documentHeightPx: number, + pptxHeightInches: number, +): number { + if (!isFinite(fontSizePx) || fontSizePx <= 0) return 11; + const ptPerPx = (pptxHeightInches * 72) / documentHeightPx; + return Math.round(fontSizePx * ptPerPx * 100) / 100; +} + +/** Clamp a container-query based font size (mirrors browser clamp() rules). */ +export function fontSizeFromCqw( + factor: number, + minPx: number, + maxPx: number, + containerWidthPx: number, +): number { + const raw = factor * (containerWidthPx / 100); + return Math.min(maxPx, Math.max(minPx, raw)); +} + +function sanitizeDimension(value: number, fallback: number): number { + return isFinite(value) && value > 0 ? value : fallback; +} + +/** + * Validate a frame before it is exported. Returns a list of human-readable + * errors. MISSING geometry is distinguished from a legitimate 0 coordinate: + * an explicit x=0 is valid; an undefined/NaN/negative/zero-size frame is not + * and must never be silently placed at the top-left corner. + */ +export function validateFrame( + source: Partial>, + options?: { allowOutside?: boolean }, +): string[] { + const errors: string[] = []; + for (const key of ['x', 'y', 'w', 'h'] as const) { + const value = source[key]; + if (value === undefined) { + errors.push(`missing "${key}"`); + continue; + } + if (typeof value !== 'number' || !isFinite(value)) { + errors.push(`"${key}" is not a finite number`); + continue; + } + if ((key === 'w' || key === 'h') && value <= 0) { + errors.push(`"${key}" must be > 0 (got ${value})`); + } + if ((key === 'x' || key === 'y') && value < 0) { + errors.push(`"${key}" must be >= 0 (got ${value})`); + } + } + return errors; +} + +/** Full validation of a rect against the document bounds (Phase 16 diagnostics). */ +export function validateRectWithinSlide( + rect: Partial>, + documentWidthPx: number, + documentHeightPx: number, + tolerance = 1, +): string[] { + const errors = validateFrame(rect); + if (errors.length) return errors; + const { x = 0, y = 0, w = 0, h = 0 } = rect as Rect; + if (x + w > documentWidthPx + tolerance) { + errors.push(`rect exceeds slide width (x+w=${x + w} > ${documentWidthPx})`); + } + if (y + h > documentHeightPx + tolerance) { + errors.push(`rect exceeds slide height (y+h=${y + h} > ${documentHeightPx})`); + } + return errors; +} + +/** True when a rect is valid enough to be placed (never false-negatives). */ +export function isUsableFrame(source: Partial>): boolean { + return validateFrame(source).length === 0; +} \ No newline at end of file diff --git a/skills/deckforge/starter-components/export/image-dimensions.ts b/skills/deckforge/starter-components/export/image-dimensions.ts new file mode 100644 index 0000000..c5c18c2 --- /dev/null +++ b/skills/deckforge/starter-components/export/image-dimensions.ts @@ -0,0 +1,167 @@ +export interface IntrinsicImageSize { + width: number; + height: number; +} + +function toBytes(dataUri: string): Uint8Array { + const comma = dataUri.indexOf(","); + if (comma < 0) { + return new Uint8Array(0); + } + const header = dataUri.slice(0, comma); + const payload = dataUri.slice(comma + 1); + if (/;base64$/i.test(header)) { + const binary = atob(payload); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; + } + const decoded = decodeURIComponent(payload); + const bytes = new Uint8Array(decoded.length); + for (let i = 0; i < decoded.length; i += 1) { + bytes[i] = decoded.charCodeAt(i); + } + return bytes; +} + +function readPng(bytes: Uint8Array): IntrinsicImageSize | null { + if (bytes.length < 24) { + return null; + } + const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + for (let i = 0; i < signature.length; i += 1) { + if (bytes[i] !== signature[i]) { + return null; + } + } + if (bytes[12] !== 0x49 || bytes[13] !== 0x48 || bytes[14] !== 0x44 || bytes[15] !== 0x52) { + return null; + } + const width = (bytes[16] << 24) | (bytes[17] << 16) | (bytes[18] << 8) | bytes[19]; + const height = (bytes[20] << 24) | (bytes[21] << 16) | (bytes[22] << 8) | bytes[23]; + if (width <= 0 || height <= 0) { + return null; + } + return { width, height }; +} + +function readGif(bytes: Uint8Array): IntrinsicImageSize | null { + if (bytes.length < 10) { + return null; + } + if (bytes[0] !== 0x47 || bytes[1] !== 0x49 || bytes[2] !== 0x46) { + return null; + } + const width = bytes[6] | (bytes[7] << 8); + const height = bytes[8] | (bytes[9] << 8); + if (width <= 0 || height <= 0) { + return null; + } + return { width, height }; +} + +function readJpeg(bytes: Uint8Array): IntrinsicImageSize | null { + if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) { + return null; + } + let offset = 2; + while (offset + 4 <= bytes.length) { + if (bytes[offset] !== 0xff) { + offset += 1; + continue; + } + const marker = bytes[offset + 1]; + if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) { + offset += 2; + continue; + } + if (marker === 0xff || marker === 0x01) { + offset += 2; + continue; + } + const length = (bytes[offset + 2] << 8) | bytes[offset + 3]; + if (length < 2 || offset + 2 + length > bytes.length) { + return null; + } + const isSof = + marker === 0xc0 || + marker === 0xc1 || + marker === 0xc2 || + marker === 0xc3 || + marker === 0xc5 || + marker === 0xc6 || + marker === 0xc7 || + marker === 0xc9 || + marker === 0xca || + marker === 0xcb || + marker === 0xcd || + marker === 0xce || + marker === 0xcf; + if (isSof) { + if (offset + 9 > bytes.length) { + return null; + } + const height = (bytes[offset + 5] << 8) | bytes[offset + 6]; + const width = (bytes[offset + 7] << 8) | bytes[offset + 8]; + if (width <= 0 || height <= 0) { + return null; + } + return { width, height }; + } + offset += 2 + length; + } + return null; +} + +function readWebP(bytes: Uint8Array): IntrinsicImageSize | null { + if (bytes.length < 30) { + return null; + } + if (bytes[0] !== 0x52 || bytes[1] !== 0x49 || bytes[2] !== 0x46 || bytes[3] !== 0x46) { + return null; + } + if (bytes[8] !== 0x57 || bytes[9] !== 0x45 || bytes[10] !== 0x42 || bytes[11] !== 0x50) { + return null; + } + const chunkHeader = String.fromCharCode(bytes[12], bytes[13], bytes[14], bytes[15]); + if (chunkHeader === "VP8X") { + const width = 1 + (bytes[24] | (bytes[25] << 8) | (bytes[26] << 16)); + const height = 1 + (bytes[27] | (bytes[28] << 8) | (bytes[29] << 16)); + if (width <= 0 || height <= 0) { + return null; + } + return { width, height }; + } + if (chunkHeader === "VP8 " && bytes[20] !== 0x2f) { + const width = (bytes[26] | (bytes[27] << 8)) & 0x3fff; + const height = (bytes[28] | (bytes[29] << 8)) & 0x3fff; + if (width <= 0 || height <= 0) { + return null; + } + return { width, height }; + } + if (chunkHeader === "VP8L") { + const width = 1 + (((bytes[22] & 0x3f) << 8) | bytes[21]); + const height = 1 + (((bytes[24] & 0x0f) << 10) | (bytes[23] << 2) | ((bytes[22] & 0xc0) >> 6)); + if (width <= 0 || height <= 0) { + return null; + } + return { width, height }; + } + return null; +} + +export function readImageSizeFromDataUri(dataUri: string): IntrinsicImageSize | null { + if (typeof dataUri !== "string" || !dataUri.startsWith("data:")) { + return null; + } + const bytes = toBytes(dataUri); + if (bytes.length === 0) { + return null; + } + return ( + readPng(bytes) ?? readJpeg(bytes) ?? readGif(bytes) ?? readWebP(bytes) + ); +} diff --git a/skills/deckforge/starter-components/export/pptx/block-exporters/process.ts b/skills/deckforge/starter-components/export/pptx/block-exporters/process.ts new file mode 100644 index 0000000..a1ea3d4 --- /dev/null +++ b/skills/deckforge/starter-components/export/pptx/block-exporters/process.ts @@ -0,0 +1,236 @@ +// export/pptx/block-exporters/process.ts +// +// Native PPTX export for `process` blocks. A process is rendered as editable +// PowerPoint shapes with real text runs — never a screenshot. The layout is the +// web renderer's vertical numbered list (render/BlockRenderer.tsx ProcessBlock + +// styles.css `.block-process`): +// +// 01 <- code-font index, bold, secondary color +// <detail> <- muted detail below the title +// +// process node -> editable text rows stacked vertically inside the frame +// step index -> its own "01".."0N" column on the left +// node title/body -> styled text runs (bold title + muted detail) +// +// The old horizontal "card row + right-arrow connectors" layout was removed +// because it did not match the webapp; connectors would have to be re-added +// only if a future web design uses them. Fallback hierarchy honoured: +// 1) native editable primitives, 2) SVG, 3) raster, 4) fatal only when the +// source content itself is unavailable. A missing frame is a geometry error and +// returns "unsupported" — never a (0,0) placeholder. + +import type { + ExportIssue, + PptxBlockExport, + PptxBlockExporter, + PptxExportContext, + PptxSlideElement, +} from "../../export-types"; +import { resolveTheme, hexToPptx } from "../../resolved-theme"; +import { + browserTypographyFor, + exportFrameOf, + fontSizeToPpt, + frameErrorIssue, + pptFontFor, +} from "../export-utils"; +import { fontSizeFromCqw } from "../../geometry"; +import type { Block } from "../../../deck/types"; + +interface ProcessStepContent { + title?: unknown; + detail?: unknown; +} + +interface ProcessContent { + steps?: ProcessStepContent[]; +} + +const INDEX_GLYPH_FACTOR = 1.1; +const BODY_GAP_PX = 12.8; +const ROW_GAP_PX = 11.2; +const INDEX_TOP_PAD_PX = 3.2; + +function stepTitle(step: ProcessStepContent | undefined, index: number): string { + if (!step) return `Step ${index + 1}`; + const title = typeof step.title === "string" ? step.title : ""; + return title || `Step ${index + 1}`; +} + +function stepDetail(step: ProcessStepContent | undefined): string { + return typeof step?.detail === "string" ? step.detail : ""; +} + +async function exportProcessBlock( + block: unknown, + ctx: PptxExportContext, +): Promise<PptxBlockExport> { + const processBlock = block as Block; + const frame = exportFrameOf(processBlock); + const issues: ExportIssue[] = []; + if (!frame) { + return { + status: "unsupported", + issues: [frameErrorIssue(processBlock.id, "process blocks require a resolved frame")], + }; + } + + const theme = resolveTheme(ctx.deck); + const content = processBlock.content as ProcessContent | undefined; + const steps = Array.isArray(content?.steps) ? content.steps : []; + + const elements: PptxSlideElement[] = []; + const bodyFont = pptFontFor(theme.typography.bodyFont, ctx); + const codeFont = pptFontFor(theme.typography.codeFont, ctx); + const typography = browserTypographyFor(processBlock, frame.w); + + // Web `.process-step` typography: index < title > detail. Each size is an + // independent cqw clamp from styles.css (not derived from the title): + // title -> clamp(13px,1.6cqw,18px), index -> clamp(11px,1.4cqw,15px), + // detail -> clamp(12px,1.5cqw,16px). + const titlePx = typography.fontSizePx; + const clampCqw = (factor: number, min: number, max: number): number => + Math.round(fontSizeFromCqw(factor, min, max, frame.w) * 100) / 100; + const detailPx = clampCqw(1.5, 12, 16); + const indexPx = clampCqw(1.4, 11, 15); + const titlePt = fontSizeToPpt(titlePx, ctx); + const detailPt = fontSizeToPpt(detailPx, ctx); + const indexPt = fontSizeToPpt(indexPx, ctx); + + if (steps.length === 0) { + // Content present but no steps: still preserve the block as an editable + // text shape rather than silently dropping it. + const fallbackText = + typeof processBlock.content === "string" + ? processBlock.content + : processBlock.alt || "Process"; + elements.push({ + type: "text", + elementId: processBlock.id, + x: frame.x, + y: frame.y, + w: frame.w, + h: frame.h, + data: { + text: fallbackText, + options: { + fontFace: bodyFont, + fontSize: titlePt, + bold: true, + color: hexToPptx(theme.tokens.foreground), + align: "left", + valign: "top", + wrap: true, + breakLine: true, + autoFit: false, + margin: 0, + }, + }, + }); + } else { + // Web `.block-process` is a flex column at natural height, packed from the + // top (no frame-filling stretch): rows stack with `gap: 0.7em` (11.2px) + // and each row is a flex row with `gap: 0.8em` (12.8px) between the index + // glyphs and the body. The index has `padding-top: 0.2em` (3.2px). + const indexW = indexPx * INDEX_GLYPH_FACTOR; + const bodyX = frame.x + indexW + BODY_GAP_PX; + const bodyW = Math.max(40, frame.w - indexW - BODY_GAP_PX); + const lineH = (px: number) => px * typography.lineHeight; + let y = frame.y; + + for (let i = 0; i < steps.length; i++) { + const title = stepTitle(steps[i], i); + const detail = stepDetail(steps[i]); + const titleH = lineH(titlePx); + const detailH = detail ? lineH(detailPx) : 0; + const rowH = Math.max(lineH(indexPx), titleH + detailH); + + // Index column: "01".."0N" in the code font, bold, secondary color. + elements.push({ + type: "text", + elementId: processBlock.id, + x: frame.x, + y: y + INDEX_TOP_PAD_PX, + w: indexW, + h: rowH, + data: { + text: String(i + 1).padStart(2, "0"), + options: { + fontFace: codeFont, + fontSize: indexPt, + bold: true, + color: hexToPptx(theme.tokens.secondary), + align: "left", + valign: "top", + wrap: true, + breakLine: true, + autoFit: false, + margin: 0, + lineSpacingMultiple: typography.lineHeight, + }, + }, + }); + + // Body: bold title run, then a muted detail run on the next line. + const runs: Array<{ text: string; options: Record<string, unknown> }> = [ + { + text: title, + options: { + fontFace: bodyFont, + fontSize: titlePt, + bold: true, + color: hexToPptx(theme.tokens.foreground), + }, + }, + ]; + if (detail) { + runs.push({ + text: detail, + options: { + fontFace: bodyFont, + fontSize: detailPt, + bold: false, + color: hexToPptx(theme.tokens.muted), + breakLine: true, + }, + }); + } + + elements.push({ + type: "text", + elementId: processBlock.id, + x: bodyX, + y, + w: bodyW, + h: rowH, + data: { + text: runs, + options: { + align: "left", + valign: "top", + wrap: true, + breakLine: true, + autoFit: false, + margin: 0, + lineSpacingMultiple: typography.lineHeight, + }, + }, + }); + + y += rowH + ROW_GAP_PX; + } + } + + return { + status: "native", + issues, + element: elements[0], + elements, + }; +} + +export const processBlockExporter: PptxBlockExporter = { + type: "process", + exportability: "native-editable", + export: exportProcessBlock, +}; diff --git a/skills/deckforge/starter-components/export/pptx/export-utils.ts b/skills/deckforge/starter-components/export/pptx/export-utils.ts new file mode 100644 index 0000000..1c3bc72 --- /dev/null +++ b/skills/deckforge/starter-components/export/pptx/export-utils.ts @@ -0,0 +1,159 @@ +// export/pptx/export-utils.ts +// +// Shared, purely-derived helpers for the PPTX block exporters so no single +// exporter can invent its own coordinate or font conversion (Phases 5/7/10). +// +// RULES enforced here: +// - Geometry comes ONLY from the resolved document frame. Missing/malformed +// frames are errors, never silently placed at (0,0). +// - Font sizes are derived from the browser (document) typography and mapped +// to PPT points via the geometry layer. + +import type { ExportIssue, PptxExportContext } from "../export-types"; +import type { Block } from "../../deck/types"; +import { + browserFontSizeToPptPt, + fontSizeFromCqw, + isUsableFrame, + validateFrame, + type Rect, +} from "../geometry"; +import { resolvePptxFont } from "./pptx-fonts"; + +/** Extract an element rect from a resolved block frame; undefined when invalid. */ +export function exportFrameOf(block: Block): Rect | undefined { + // Prefer the resolved frame (canonical geometry pipeline) over the raw + // persisted frame so slot/flow blocks always export at their real location. + const frame = block.resolvedFrame ?? block.frame; + if (!frame) return undefined; + const rect = { x: frame.x, y: frame.y, w: frame.w, h: frame.h }; + return isUsableFrame(rect) ? rect : undefined; +} + +const FRAME_ERROR_CODES = new Set([ + "missing-frame", +]); + +export function frameErrorIssue(blockId: string, detail: string): ExportIssue { + return { + code: "block-export-failed", + severity: "error", + message: `Block "${blockId}" has invalid geometry: ${detail}. The block was not exported.`, + suggestedFix: "Give the block a valid frame (x, y, w > 0, h > 0) in the editor", + automaticFixAvailable: false, + }; +} + +/** Full geometry validation errors for a block frame (used for diagnostics). */ +export function frameValidation(block: Block): string[] { + const frame = block.frame; + if (!frame) return ["missing frame"]; + return validateFrame({ x: frame.x, y: frame.y, w: frame.w, h: frame.h }); +} + +/** Text block type -> base styling that mirrors render/BlockRenderer.tsx. */ +export interface BrowserTypography { + fontSizePx: number; + lineHeight: number; + bold: boolean; + italic: boolean; + letterSpacingEm?: number; +} + +const clamp = fontSizeFromCqw; + +function clampTo(width: number, factor: number, min: number, max: number): number { + return Math.round(clamp(factor, min, max, width) * 100) / 100; +} + +/** + * Resolve the browser-equivalent typography for a block given its rendered + * container width (in document pixels). Kept in sync with BlockRenderer/styles: + * a block must look the same on the exported slide as in the browser. + */ +export function browserTypographyFor(block: Block, containerWidthPx: number): BrowserTypography { + const style = block.style ?? {}; + const variant = typeof style.variant === "string" ? style.variant : ""; + const level = typeof style.level === "number" ? style.level : 3; + const w = containerWidthPx > 0 ? containerWidthPx : 1; + + switch (block.type) { + case "heading": + if (level === 1) { + return { + fontSizePx: clampTo(w, 4.2, 34, 52), + lineHeight: 1.05, + bold: false, + italic: false, + letterSpacingEm: -0.02, + }; + } + // All headings render as <h2> on the web (BlockRenderer), and h2 keeps + // the browser default font-weight: bold. Only level 1 overrides weight + // to 400, so every other level is bold. + return { fontSizePx: 24, lineHeight: 1.25, bold: true, italic: false }; + case "caption": + return { fontSizePx: 13, lineHeight: 1.5, bold: true, italic: false }; + case "bullets": + // `.block-bullets` inherits the 16px body font-size (styles.css has no + // font-size on the list), so lines are 16px with a normal ~1.2 line box. + return { fontSizePx: 16, lineHeight: 1.2, bold: false, italic: false }; + case "citation": + // `.block-citation` sets no font-style, so citations are NOT italic. + return { fontSizePx: clampTo(w, 1.2, 10, 13), lineHeight: 1.5, bold: false, italic: false }; + case "callout": + return { fontSizePx: clampTo(w, 1.7, 14, 19), lineHeight: 1.5, bold: false, italic: true }; + case "metric": + return { fontSizePx: clampTo(w, 9, 64, 128), lineHeight: 1.0, bold: true, italic: false }; + case "process": + return { fontSizePx: clampTo(w, 1.6, 13, 18), lineHeight: 1.4, bold: true, italic: false }; + default: + if (variant === "kicker") return { fontSizePx: 12, lineHeight: 1.4, bold: true, italic: false, letterSpacingEm: 0.14 }; + if (variant === "meta") return { fontSizePx: 13, lineHeight: 1.5, bold: false, italic: false }; + if (variant === "caption") return { fontSizePx: 13, lineHeight: 1.5, bold: true, italic: false }; + // Inline variant (BlockRenderer styleFrom) fixes 15px; the `.block-callout` + // class clamps instead — that branch is handled by the "callout" case above. + if (variant === "callout") return { fontSizePx: 15, lineHeight: 1.5, bold: false, italic: true }; + return { fontSizePx: clampTo(w, 1.6, 14, 20), lineHeight: 1.55, bold: false, italic: false }; + } +} + +/** Convert a browser font size (px, document units) to PPT points. */ +export function fontSizeToPpt(fontSizePx: number, ctx: PptxExportContext): number { + return browserFontSizeToPptPt(fontSizePx, ctx.slideHeight, ctx.pptxHeight); +} + +/** Resolve the PPT-safe font family for a web font name. */ +export function pptFontFor(webFont: string | undefined, ctx: PptxExportContext): string { + const resolved = resolvePptxFont(webFont ?? "Arial"); + if (webFont && resolved !== webFont) { + ctx.fontWarnings.push({ + fontFamily: webFont, + substituteFont: resolved, + }); + } + return resolved; +} + +/** Shared baseline text frame options (margins disabled, no autofit surprises). */ +export function textFrameOptions( + base: Record<string, unknown>, + ctx: PptxExportContext, +): Record<string, unknown> { + return { + margin: 0, + wrap: true, + breakLine: true, + autoFit: false, + ...base, + }; +} + +/** Estimate a text-height-in-document-px heuristic (mirrors measure.ts). */ +export function estimateTextHeightPx(text: string, fontSizePx: number, widthPx: number): number { + const charsPerLine = Math.max(8, Math.floor(widthPx / (fontSizePx * 0.5))); + const lines = Math.max(1, Math.ceil(text.length / charsPerLine)); + return Math.ceil(lines * fontSizePx * 1.5) + (fontSizePx * 0.4); +} + +export { FRAME_ERROR_CODES }; \ No newline at end of file diff --git a/skills/deckforge/starter-components/export/pptx/pptx-placeholder.ts b/skills/deckforge/starter-components/export/pptx/pptx-placeholder.ts new file mode 100644 index 0000000..99ec7dc --- /dev/null +++ b/skills/deckforge/starter-components/export/pptx/pptx-placeholder.ts @@ -0,0 +1,17 @@ +// export/pptx/pptx-placeholder.ts +// +// Bundled, deterministic placeholder IMAGE used when a remote/manifest asset +// cannot be resolved at export time (e.g. offline or a dead URL). +// +// Regression (P2-004): the image exporter used to replace an unresolvable +// image with a labeled TEXT box ("Image unavailable: …"). That is forbidden by +// the export contract: it leaks placeholder text into the deck and violates +// "never export [image unavailable: …]" and "never silently drop the image". +// Instead, a real raster element is embedded at the resolved frame — the visual +// slot is filled, the image "appears in PPTX", and the fallback stays a +// fidelity-first substitution rather than a missing block. +// +// 480x360 theme-integrated "image" glyph (cream gradient, frame, sun + peak). + +export const PLACEHOLDER_IMAGE_DATA_URI = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAeAAAAFoCAIAAAAAVb93AAAIl0lEQVR42u3YSVIbQRRFUS2XrbAXBuyARTAxPYhGom+M7akn9oAsBUK2VPUy60ScBYjU+5cITX693QIQaOIJAAQaAIEGEGgABBpAoAEQaAAEGkCgARBogLYDPQcg0OTn6xyAQAININAACDSAQAMg0AACDYBAAyDQAAINgEADCDQAAg2AQAMINAACDSDQAAg0gEADINAALA/0DIBAAg2QGugfLzMAAgk0gEADINAAAg2AQAMINAACDYBAAwg0AAININAACDQAAg0g0AAINIBAAyDQAAINQFSgbwAIJNAAAg2AQAO0EOj35xsAAgk0gEADINAAAg2AQAMINAACDYBAAwg0AAININAACDQAAr02u9tbQJc4CLQig14LNNIMMi3Q0gwyzeT9+ZqlnBNsoNHasoRAr6fOXgkcjkAHjczjgDsS6LhVeRlwUAKduCfPAm6qp0B/f7pmoYVL8izgsnoj0CvMyJuA4xJoAwInhkBbDzgxga56PR4EXJlAmw64MgTadMCVCbRfxwCHJtD+sYNbE2ijAdyaQBsNuDWBxmjArQm00QBuTaCNBtyaQBsN4NYE2mjArQm00RgNuLUBA31FoTMabwJubQACbTTg1gTaaAC3JtBGA25NoI0GcGsbC/Tb4xWFYjQeBNzaIATaaMCtCbTRAG5NoI0G3JpAGw3g1gTaaMCtCTRGA25NoI0G3JpbE2ijAbcm0EYDuDWBNhpwawJtNIBbE2ijAbeGQBsNuDWBNhrArQm00YBbaynQlxQ6o/Em4NYGINBGA25NoI0GcGsCbTTg1gTaaAC3JtBGA25NoDEacGsRgX59uKRQjGZUf/v+3o4B4NZCCPRIR7O/t7Mqw8CtCbTRZHVZqXFrAm00FaRZpnFrAm00uV1WatyaQBtNHXXWaNyaQBtNaJplGoEWaKNJr7NGI9ACbTS5ddZoBFqgjSY0zTKNQAu00aTXWaMRaIE2mtw6azQCLdBGI9C4NYE2GnXWaAS690BPKXRGk/tRw+v8t9FGRfW3NgiBrng0VdRZoxFogRZogcatCbTRqLNGI9ACbTTN1FmjEWiBFmiBxq0JtNEItEAj0AJtNG3UWaMR6JUD/XI/pVCMJu3jVR1o66KiWxucQFc2mqrrrNEItEALtEDj1gTaaARaoBFogTaaNuqs0Qi0QAu0QOPWBNpoBFqgEWiBNhqBxq0JtNEItEAj0AJtNAKNWxNogRZogcatCbTRCLRAI9ACPabRNFNnjUagvx7oCwqd0aR8sIbqbGZE31oIgRZogcatCbTRCLRAI9ACLdACjVsTaKMRaIFGoAXaaASapg9NoAVaoAWaxDQLtEALtEATmmaBFugWfhdTZ5pMs0ALtEALNKFpFujFgX6+u6BQjCbqszUQaAMb5x19hUcrCHRlga690dYlzQIt0AIt0NSUZoEW6EYCXW+jTUuaBVqgBVqgqSzNAi3QAi3QhKZZoAW6nUDX2Gi70mWBFuixBLquRhuVNAu0QAu0QFNlmgVaoFsLdC2NtihpFmiBHmOg8xttTtIs0P8T6HMKndGkf+DgOptT9fvvN9De/wOBFmiBZvg0C7RANxvozEYbkjQLtEALdGKjrUiaBVqgBTou0/YjzQIt0AKd2GjjkWaBFmiBTmy05eiyQAu0QMdl2makWaAFWqATG20w0izQAi3QWaW2E2kWaIEW6LhMW4g0C3RPgX66PadQjKbJv/EfumwYde22Rr7EgkCPNNCfhNsApFmgBVqgQZrdmkALNNIs0AIt0CDNAi3QAo00C7RAC7Q3QZcFWqAFGsaZZrcm0AKNNAu0QAs0SLNAryPQZxQ6o/EmDDC8UQbaDD4QaIFGlwVaoAUapFmgBVqgkWaBFmiBBmkWaIEWaKRZoAUao0GaBVqgBRpdxq0JtEAjzQIt0AKN8SDQAi3QSLNAC7RAgzQLtEALNLos0PwJ9OP8jEIxGg/C5wthXUyrINACjTQLtEALNNKMQAu0QCPNAi3QAo00I9ACLdDoskALNEbjq0egBVqgkWbc2meBPqXQGY03GdHXzaCBNsgPBFqgfdEItEALNLqMQAu0QCPNAi3QAo00I9ACLdBIs0ALNEYjzQi0QAs00oxbE2iB1mUEWqAFGmlGoAVaoJFmgRZogUaaEWiBFmikWaARaIHWZQQ6O9APs1MKxWg8SNo3QqtMvSDQAi3NCLRACzTSjEALtEBLMwIt0AKNNCPQAi3QuoxACzRGI80IdEigTyh0RuNNNv7I4Na6BFqgpRmBFmiB9rAahEALtEDrMgIt0AINuDWBNhpwawKN0YBbE2ijAbfm1gTaaMCtCbTRAG5NoI0G3JpAGw3g1gTaaMCtIdBGA25NoI0GcGsCbTTg1toJ9P3NCYViNB4E3NogBNpowK0JtNEAbk2gjQbcmkAbDeDWNhjoYwqd0XgTcGsDEGijAbcm0EYDuDWBNhpwawJtNIBbE2ijAbcm0BgNuDWBNhpwa25NoI0G3JpAGw3g1gS6p9HYDTg0gfaPHVwZAm064MoE2nQAVybQfh0DJybQ1gM4MYE2IHBc4w303fUxC3U3tLu95VnAZfVGoC0J3JRAtzImewIHJdDRk7IqcEcCHb0tIwOHs9FAH7HUV6YGrERYlhJojQZ1FmiZBqRZoGUapFmg0WtQZIEGEGgABBoAgQYQaAAEGkCgARBoAAQaQKABEGgAgQZgw4G+vToCIJBAAwg0AAIN0EagDwEIJNAAAg2AQAMINAACDSDQAAg0AAININAACDSAQAMg0AAINIBAAyDQAAINgEADCDQAQYGeXx4CEEigAXIDfQBAIIEGEGgABBpAoAEQaACBBkCgARBoAIEGQKABBBoAgQZAoAEEGgCBBhBoAAQaQKABEGgAlgV6Nj0AINBkNv0GQCCBBhBoAAQaQKABEGgAgQZAoAEQaACBBkCgAVr2G4EF3RgL5/rzAAAAAElFTkSuQmCC"; diff --git a/skills/deckforge/starter-components/export/prepare-export.ts b/skills/deckforge/starter-components/export/prepare-export.ts new file mode 100644 index 0000000..b621987 --- /dev/null +++ b/skills/deckforge/starter-components/export/prepare-export.ts @@ -0,0 +1,181 @@ +// export/prepare-export.ts +// +// THE single asynchronous export-preparation phase. +// +// `prepareExport` is the ONLY place that performs network/asset work. It +// resolves every required visible image source to embeddable data URIs exactly +// once, builds the canonical asset registry (keyed by canonical asset id: +// manifest id or `inline:<blockId>`), and freezes an immutable snapshot of the +// deck. Preflight, fidelity accounting, and the PPTX exporter all consume the +// resulting `PreparedExport` and must never re-resolve or reinterpret assets. +// +// Contract: +// - Preflight operates on the PREPARED snapshots + registry, so "Ready to +// export" can never be printed while an unresolved required image exists. +// - The exporter consumes the registry, so it can never fail mid-export on a +// URL that preflight said was fine. +// - Each required source is fetched at most once per preparation. +// +// Type-safety note: `deck.assets` is technically optional on DeckProject, but +// every deck produced by this app carries a manifest array; we coerce to a +// stable array so registry lookups never see "undefined assets". + +import type { DeckProject } from "../deck/types"; +import type { PptxExportConfig } from "./export-types"; +import { canonicalAssetRef } from "../deck/assets"; +import { resolveSlideSnapshot, type ImmutableSlideSnapshot } from "./snapshot"; +import { embedAssetDetailed, type AssetEmbedResult } from "./pptx/pptx-assets"; + +export type PreparedAssetStatus = "ready" | "failed"; + +/** One entry in the canonical, pre-resolved asset registry. */ +export interface PreparedAsset { + /** Canonical registry key (manifest id or `inline:<blockId>`). */ + assetId: string; + /** The image block that first required this asset. */ + blockId?: string; + /** The concrete source URL (or data URI) the asset was resolved from. */ + originalSrc: string; + /** The embeddable data URI; empty when resolution failed. */ + resolvedDataUri: string; + mimeType: string; + width?: number; + height?: number; + status: PreparedAssetStatus; + /** Why resolution failed (network error, HTTP status, CORS, orphan, …). */ + error?: string; +} + +/** The frozen result of the single preparation phase. */ +export interface PreparedExport { + deck: DeckProject; + config: PptxExportConfig; + /** Canonical asset registry consumed by preflight + exporters. */ + assets: ReadonlyMap<string, PreparedAsset>; + /** Immutable snapshots for every slide selected by the config. */ + slides: ImmutableSlideSnapshot[]; +} + +/** Type guard discriminating a `PreparedExport` from a raw `DeckProject`. */ +export function isPreparedExport(value: unknown): value is PreparedExport { + return ( + !!value && + typeof value === "object" && + "config" in value && + "assets" in value && + !Array.isArray((value as { assets?: unknown }).assets) + ); +} + +interface RequiredAsset { + assetId: string; + blockId: string; + src?: string; + orphan?: boolean; +} + +/** + * Collect every required, visible image source. Manifest-backed blocks use + * their asset id as the canonical key; legacy inline `content.src`/`block.src` + * sources get a deterministic synthetic key. Placeholder blocks (no source) + * are intentionally not collected — they are rendered as a designed + * placeholder and never count against fidelity. + */ +function collectRequiredAssets(deck: DeckProject, includeHiddenSlides: boolean): RequiredAsset[] { + const required = new Map<string, RequiredAsset>(); + for (const slide of deck.slides) { + if (!includeHiddenSlides && slide.hidden) continue; + for (const block of slide.blocks) { + if (block.hidden || block.type !== "image") continue; + const ref = canonicalAssetRef(deck, block); + if (!ref) continue; + if (!required.has(ref.assetId)) { + required.set(ref.assetId, { + assetId: ref.assetId, + blockId: block.id, + src: ref.src, + orphan: ref.orphan, + }); + } + } + } + return [...required.values()]; +} + +async function buildAssetRegistry( + deck: DeckProject, + config: PptxExportConfig, +): Promise<PreparedAsset[]> { + const cache = new Map<string, AssetEmbedResult>(); + const entries: PreparedAsset[] = []; + + for (const req of collectRequiredAssets(deck, config.includeHiddenSlides)) { + const manifestAsset = !req.assetId.startsWith("inline:") + ? (deck.assets ?? []).find((asset) => asset.id === req.assetId) + : undefined; + + if (req.orphan) { + entries.push({ + assetId: req.assetId, + blockId: req.blockId, + originalSrc: "", + resolvedDataUri: "", + mimeType: manifestAsset?.mimeType ?? "image/png", + status: "failed", + error: `Image block "${req.blockId}" references asset "${req.assetId}" which has no manifest entry`, + }); + continue; + } + + const src = req.src ?? ""; + if (!src) { + entries.push({ + assetId: req.assetId, + blockId: req.blockId, + originalSrc: "", + resolvedDataUri: "", + mimeType: manifestAsset?.mimeType ?? "image/png", + status: "failed", + error: `Image block "${req.blockId}" has no resolvable source`, + }); + continue; + } + + const { result, error } = await embedAssetDetailed(src, cache); + entries.push({ + assetId: req.assetId, + blockId: req.blockId, + originalSrc: src, + resolvedDataUri: result.dataUri, + mimeType: result.mimeType || manifestAsset?.mimeType || "image/png", + width: manifestAsset?.width, + height: manifestAsset?.height, + status: result.dataUri ? "ready" : "failed", + error: result.dataUri + ? undefined + : `Image "${src}" (block "${req.blockId}") could not be fetched (${error ?? "network error, 404, CORS, or timeout"})`, + }); + } + + return entries; +} + +/** + * Prepare a deck for export. This is the ONE place assets are resolved; every + * downstream consumer (preflight, fidelity, PPTX exporter) must be handed the + * returned `PreparedExport` and must not perform its own resolution. + */ +export async function prepareExport( + deck: DeckProject, + config: PptxExportConfig, +): Promise<PreparedExport> { + const assetEntries = await buildAssetRegistry(deck, config); + const assets = new Map<string, PreparedAsset>(); + for (const entry of assetEntries) assets.set(entry.assetId, entry); + + const slides = deck.slides + .filter((slide) => config.includeHiddenSlides || !slide.hidden) + .map((slide) => resolveSlideSnapshot(slide, deck, assets)); + + return { deck, config, assets, slides }; +} \ No newline at end of file diff --git a/skills/deckforge/starter-components/export/resolved-theme.ts b/skills/deckforge/starter-components/export/resolved-theme.ts new file mode 100644 index 0000000..6ff78c4 --- /dev/null +++ b/skills/deckforge/starter-components/export/resolved-theme.ts @@ -0,0 +1,253 @@ +// export/resolved-theme.ts +// +// THE single source of truth for theme resolution. +// This module provides a resolver that creates a fully-resolved theme +// from the DeckProject's theme configuration. +// +// The resolved theme is used by both the Web Renderer and PPTX Exporter +// to ensure color/typography parity. + +import type { DeckProject, ThemeDef, ThemeTokens } from "../deck/types"; +import { getTheme } from "../deck/themes"; + +// ─── Canonical Color Resolution ────────────────────────────────────────────── + +/** + * Normalize a CSS color to a canonical hex format. + * This ensures consistent color representation across all renderers. + */ +export function normalizeColor(color: string): string { + if (!color) return "#000000"; + + // Already hex + if (color.startsWith("#")) { + const hex = color.replace("#", ""); + if (hex.length === 3) { + return `#${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}`; + } + return `#${hex.slice(0, 6)}`; + } + + // RGB/RGBA + if (color.startsWith("rgb")) { + const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + if (match) { + const r = parseInt(match[1], 10).toString(16).padStart(2, "0"); + const g = parseInt(match[2], 10).toString(16).padStart(2, "0"); + const b = parseInt(match[3], 10).toString(16).padStart(2, "0"); + return `#${r}${g}${b}`; + } + } + + // Named colors - return as-is (CSS will handle) + return color; +} + +/** + * Parse a hex color to RGB components. + */ +export function hexToRgb(hex: string): { r: number; g: number; b: number } | null { + const normalized = normalizeColor(hex); + const match = normalized.match(/^#([0-9a-f]{6})$/i); + if (!match) return null; + + const hexStr = match[1]; + return { + r: parseInt(hexStr.slice(0, 2), 16), + g: parseInt(hexStr.slice(2, 4), 16), + b: parseInt(hexStr.slice(4, 6), 16), + }; +} + +/** + * Convert a hex color to PPTX format (without # prefix). + */ +export function hexToPptx(hex: string): string { + return normalizeColor(hex).replace("#", ""); +} + +// ─── Canonical Font Resolution ─────────────────────────────────────────────── + +const PPTX_SAFE_FONTS = new Set([ + "Arial", + "Calibri", + "Cambria", + "Candara", + "Consolas", + "Constantia", + "Corbel", + "Courier New", + "Georgia", + "Impact", + "Lucida Console", + "Palatino Linotype", + "Segoe UI", + "Tahoma", + "Times New Roman", + "Trebuchet MS", + "Verdana", +]); + +const WEB_TO_SUBSTITUTES: Record<string, string> = { + Inter: "Arial", + Manrope: "Arial", + "IBM Plex Sans": "Arial", + Sora: "Arial", + "Libre Baskerville": "Georgia", + "JetBrains Mono": "Consolas", +}; + +/** + * Resolve a web font to a PPTX-safe font family. + */ +export function resolvePptxFont(fontFamily: string): string { + if (!fontFamily) return "Arial"; + const cleanName = fontFamily.replace(/['"]/g, "").trim().split(",")[0].trim(); + if (PPTX_SAFE_FONTS.has(cleanName)) return cleanName; + if (WEB_TO_SUBSTITUTES[cleanName]) return WEB_TO_SUBSTITUTES[cleanName]; + return "Arial"; +} + +/** + * Check if a font is PPTX-safe. + */ +export function isPptxSafeFont(fontFamily: string): boolean { + const cleanName = fontFamily.replace(/['"]/g, "").trim().split(",")[0].trim(); + return PPTX_SAFE_FONTS.has(cleanName) || !!WEB_TO_SUBSTITUTES[cleanName]; +} + +// ─── Canonical Theme Resolution ────────────────────────────────────────────── + +export interface ResolvedTheme { + id: string; + tokens: ThemeTokens; + typography: { + headingFont: string; + bodyFont: string; + codeFont: string; + }; + chartPalette: string[]; + gradients: Record<string, string>; +} + +/** + * Resolve a DeckProject's theme to a fully-resolved theme. + * This is the single source of truth for all renderers. + */ +export function resolveTheme(deck: DeckProject): ResolvedTheme { + const themeDef = getTheme(deck.theme?.id ?? "editorial-cream"); + const overrides = deck.theme?.overrides ?? {}; + + // Apply overrides to tokens + const tokens: ThemeTokens = { + background: normalizeColor( + (overrides as Record<string, string>).background ?? themeDef.tokens.background + ), + foreground: normalizeColor( + (overrides as Record<string, string>).foreground ?? themeDef.tokens.foreground + ), + primary: normalizeColor( + (overrides as Record<string, string>).primary ?? themeDef.tokens.primary + ), + secondary: normalizeColor( + (overrides as Record<string, string>).secondary ?? themeDef.tokens.secondary + ), + surface: normalizeColor( + (overrides as Record<string, string>).surface ?? themeDef.tokens.surface + ), + muted: normalizeColor( + (overrides as Record<string, string>).muted ?? themeDef.tokens.muted + ), + surfaceElevated: normalizeColor( + (overrides as Record<string, string>).surfaceElevated ?? themeDef.tokens.surfaceElevated + ), + border: normalizeColor( + (overrides as Record<string, string>).border ?? themeDef.tokens.border + ), + focus: normalizeColor( + (overrides as Record<string, string>).focus ?? themeDef.tokens.focus + ), + }; + + // Apply overrides to typography + const typographyOverrides = (overrides.typography ?? {}) as Record<string, string>; + const typography = { + headingFont: typographyOverrides.headingFont ?? themeDef.typography.headingFont, + bodyFont: typographyOverrides.bodyFont ?? themeDef.typography.bodyFont, + codeFont: typographyOverrides.codeFont ?? themeDef.typography.codeFont, + }; + + // Apply overrides to chart palette + const chartPalette = Array.isArray(overrides.chartPalette) + ? (overrides.chartPalette as string[]).map(normalizeColor) + : themeDef.chartPalette.map(normalizeColor); + + // Apply overrides to gradients + const gradients = { + ...(themeDef.gradients ?? {}), + ...((overrides.gradients as Record<string, string>) ?? {}), + }; + + return { + id: themeDef.id, + tokens, + typography, + chartPalette, + gradients, + }; +} + +/** + * Get chart colors for a specific chart. + * Returns the resolved colors based on the theme's chart palette. + */ +export function resolveChartColors( + theme: ResolvedTheme, + seriesCount: number, + highlightIndex?: number +): { + seriesColors: string[]; + highlightColor: string; + axisColor: string; + gridColor: string; + labelColor: string; +} { + const palette = theme.chartPalette; + + // Generate series colors from palette + const seriesColors: string[] = []; + for (let i = 0; i < seriesCount; i++) { + seriesColors.push(palette[i % palette.length]); + } + + // Highlight color is the secondary color + const highlightColor = theme.tokens.secondary; + + return { + seriesColors, + highlightColor, + axisColor: theme.tokens.border, + gridColor: theme.tokens.border, + labelColor: theme.tokens.muted, + }; +} + +/** + * Get text color for a block based on its role. + */ +export function resolveTextColor( + theme: ResolvedTheme, + role?: "primary" | "secondary" | "muted" | "foreground" +): string { + switch (role) { + case "primary": + return theme.tokens.primary; + case "secondary": + return theme.tokens.secondary; + case "muted": + return theme.tokens.muted; + case "foreground": + default: + return theme.tokens.foreground; + } +} diff --git a/skills/deckforge/starter-components/export/self-contained.ts b/skills/deckforge/starter-components/export/self-contained.ts new file mode 100644 index 0000000..29b9bc0 --- /dev/null +++ b/skills/deckforge/starter-components/export/self-contained.ts @@ -0,0 +1,104 @@ +// export/self-contained.ts +// +// "Make deck self-contained": rewrite every required visible image source into +// an embeddable data URI so the deck exports with zero network. Pure logic with +// an injectable embedder (defaults to the real fetch-based one) so unit tests +// run deterministically offline. +// +// Contract: +// - Manifest-backed assets are rewritten in place (id, width, height kept). +// - Inline-only image blocks (canonical `inline:<blockId>` refs) are +// normalized into a NEW manifest asset so every image lives in deck.assets +// afterwards, consistent with the `updateImageSource` command. +// - data: URIs pass through untouched. +// - A failed fetch is recorded (blockId + error) and the original source is +// kept, so preflight still blocks with the block-specific message. Never +// throws. + +import type { DeckProject, DeckSlide, Block } from "../deck/types"; +import { canonicalAssetRef, imageContentOf } from "../deck/assets"; +import { newId } from "../deck/seed"; +import { embedAssetDetailed, type AssetEmbedResult, type EmbedOutcome } from "./pptx/pptx-assets"; + +export type EmbedFn = ( + assetUrl: string, + cache: Map<string, AssetEmbedResult>, +) => Promise<EmbedOutcome>; + +export interface SelfContainedFailure { + blockId: string; + assetId?: string; + error: string; +} + +export interface SelfContainedResult { + deck: DeckProject; + embedded: number; + failures: SelfContainedFailure[]; +} + +export async function makeDeckSelfContained( + deck: DeckProject, + embed: EmbedFn = embedAssetDetailed, +): Promise<SelfContainedResult> { + const cache = new Map<string, AssetEmbedResult>(); + const failures: SelfContainedFailure[] = []; + let embedded = 0; + + // Pass 1: rewrite remote manifest entries in place (id/dimensions preserved). + const assets = (deck.assets ?? []).map((asset) => ({ ...asset })); + for (const asset of assets) { + if (!asset.src || asset.src.startsWith("data:")) continue; + const { result, error } = await embed(asset.src, cache); + if (result.dataUri) { + embedded += 1; + asset.src = result.dataUri; + if (result.mimeType) asset.mimeType = result.mimeType; + } else { + failures.push({ + blockId: firstImageBlockIdFor(deck, asset.id), + assetId: asset.id, + error: error ?? "fetch failed", + }); + } + } + + // Pass 2: normalize inline-only remote image blocks into the manifest. + const slides: DeckSlide[] = []; + for (const slide of deck.slides) { + let blocks: Block[] = slide.blocks; + for (const block of blocks) { + if (block.hidden || block.type !== "image") continue; + const ref = canonicalAssetRef(deck, block); + if (!ref || !ref.assetId.startsWith("inline:")) continue; + const src = ref.src; + if (!src || src.startsWith("data:")) continue; + const { result, error } = await embed(src, cache); + if (!result.dataUri) { + failures.push({ blockId: block.id, error: error ?? "fetch failed" }); + continue; + } + const assetId = newId("asset"); + assets.push({ id: assetId, kind: "image", src: result.dataUri, mimeType: result.mimeType }); + embedded += 1; + const content = imageContentOf(block); + blocks = blocks.map((b) => + b.id === block.id ? { ...b, content: { ...content, src: undefined, assetId } } : b, + ); + } + slides.push({ ...slide, blocks }); + } + + return { deck: { ...deck, assets, slides }, embedded, failures }; +} + +/** First visible image block bound to the given manifest asset, for failure reporting. */ +function firstImageBlockIdFor(deck: DeckProject, assetId: string): string { + for (const slide of deck.slides) { + for (const block of slide.blocks) { + if (block.hidden || block.type !== "image") continue; + if (canonicalAssetRef(deck, block)?.assetId === assetId) return block.id; + } + } + return ""; +} diff --git a/skills/deckforge/starter-components/export/snapshot.ts b/skills/deckforge/starter-components/export/snapshot.ts new file mode 100644 index 0000000..ef063d1 --- /dev/null +++ b/skills/deckforge/starter-components/export/snapshot.ts @@ -0,0 +1,595 @@ +// export/snapshot.ts +// +// THE single source of truth for the immutable export snapshot. +// This module defines the canonical snapshot types and the resolver that +// creates an immutable representation of the slide at export time. +// +// Architecture: +// DeckProject +// ↓ +// Canonical SlideDocument +// ↓ +// resolveSlideSnapshot() +// ↓ +// ImmutableSlideSnapshot +// │ +// ├── Web Renderer +// ├── Present Renderer +// └── PPTX Exporter +// +// The Web Renderer and PPTX Exporter MUST NOT independently invent: +// - geometry +// - colors +// - font choices +// - default chart data +// - fallback text +// - default styling +// - missing objects +// - additional objects + +import type { + Block, + ChartContent, + ChartValue, + DeckProject, + DeckSlide, + ImageBlockContent, + ThemeDef, + ThemeTokens, +} from "../deck/types"; +import { resolveSlideGeometry, type ResolvedBlockGeometry } from "../deck/geometry-resolver"; +import { canonicalAssetRef, resolveAsset } from "../deck/assets"; +import { normalizeColor, resolveTheme, type ResolvedTheme } from "./resolved-theme"; +import type { PreparedAsset } from "./prepare-export"; + +// ─── Canonical Style Types ─────────────────────────────────────────────────── + +export interface ResolvedTextStyle { + fontFamily: string; + fontSizePx: number; + fontWeight: number; + fontStyle: "normal" | "italic"; + color: string; + lineHeight: number; + letterSpacing: number; + align: "left" | "center" | "right"; + verticalAlign: "top" | "middle" | "bottom"; + opacity: number; +} + +export interface ResolvedPaint { + type: "solid" | "gradient"; + color?: string; + gradient?: string; +} + +export interface ResolvedChartStyle { + /** + * ONE explicit hex color per category (bar). Derived from the resolved theme + * and the chart's highlightIndex so Web, Present and PPTX render identical + * bars. Never left to PowerPoint's automatic palette. + */ + seriesColors: string[]; + /** Primary bar color for non-highlighted bars (chartPalette[0]). */ + accentColor: string; + /** Color of the highlighted bar/segment (tokens.secondary). */ + highlightColor: string; + /** Foreground text color used for data labels on the web chart. */ + foreground: string; + labelColor: string; + axisColor: string; + gridColor: string; + fontFamily: string; + fontSize: number; + background: string; +} + +// ─── Canonical Block Snapshot ──────────────────────────────────────────────── + +export interface ResolvedBlockSnapshot { + id: string; + type: string; + frame: { + x: number; + y: number; + w: number; + h: number; + }; + zIndex: number; + visibility: "visible" | "hidden"; + content: unknown; + style: ResolvedTextStyle; + chartSpec?: ResolvedChartSpec; + assetSnapshot?: ResolvedAssetSnapshot; + editorOnly: boolean; + deleted: boolean; + temporary: boolean; + placeholder: boolean; +} + +// ─── Canonical Chart Spec ──────────────────────────────────────────────────── + +export interface ResolvedChartSpec { + type: "bar" | "bar-horizontal" | "line"; + orientation: "horizontal" | "vertical"; + title: string; + unit: string; + categories: string[]; + series: Array<{ + name: string; + values: number[]; + }>; + highlightIndex?: number; + summary: string; + style: ResolvedChartStyle; +} + +// ─── Canonical Asset Snapshot ──────────────────────────────────────────────── + +export interface ResolvedAssetSnapshot { + assetId: string; + resolvedSrc: string; + /** + * The pre-resolved embeddable data URI from the preparation phase. Present + * only when the asset was actually fetched and can be embedded. + */ + dataUri?: string; + /** Resolution status reported by the preparation phase. */ + status?: "ready" | "failed"; + /** Why the asset could not be resolved, when it failed. */ + error?: string; + mimeType: string; + width: number; + height: number; + alt: string; + fit: "cover" | "contain"; + focalPoint: { x: number; y: number }; + caption?: string; + attribution?: string; +} + +// ─── Canonical Theme Snapshot ──────────────────────────────────────────────── + +export interface ResolvedThemeSnapshot { + id: string; + tokens: ThemeTokens; + typography: { + headingFont: string; + bodyFont: string; + codeFont: string; + }; + chartPalette: string[]; + gradients: Record<string, string>; +} + +// ─── Canonical Slide Snapshot ──────────────────────────────────────────────── + +export interface ImmutableSlideSnapshot { + slideId: string; + title: string; + width: number; + height: number; + background: ResolvedPaint; + blocks: ResolvedBlockSnapshot[]; + theme: ResolvedThemeSnapshot; + assets: ResolvedAssetSnapshot[]; + notes?: string; + layout: string; + layoutBindings: Array<{ + slot: string; + blockIds: string[]; + flow?: "stack" | "row" | "grid" | "overlay"; + gap?: number; + }>; +} + +// ─── Style Resolution Helpers ──────────────────────────────────────────────── + +function resolveBlockTextStyle( + block: Block, + theme: Pick<ThemeDef, "tokens" | "typography">, + containerWidth: number +): ResolvedTextStyle { + const style = block.style ?? {}; + const variant = typeof style.variant === "string" ? style.variant : ""; + const level = typeof style.level === "number" ? style.level : 3; + + let fontFamily = theme.typography.bodyFont; + let fontSizePx = 16; + let fontWeight = 400; + let fontStyle: "normal" | "italic" = "normal"; + let lineHeight = 1.5; + let letterSpacing = 0; + let align: "left" | "center" | "right" = "left"; + let verticalAlign: "top" | "middle" | "bottom" = "top"; + let opacity = 1; + + // Resolve font family + if (block.type === "heading" || level === 1 || level === 3) { + fontFamily = theme.typography.headingFont; + } + + // Resolve typography based on block type and variant + switch (block.type) { + case "heading": + if (level === 1) { + fontSizePx = Math.min(52, Math.max(34, containerWidth * 0.042)); + lineHeight = 1.05; + letterSpacing = -0.02; + } else if (level === 3) { + fontSizePx = 24; + lineHeight = 1.25; + } + break; + case "metric": + fontSizePx = Math.min(128, Math.max(64, containerWidth * 0.09)); + lineHeight = 1.0; + fontWeight = 700; + verticalAlign = "middle"; + break; + case "callout": + fontSizePx = Math.min(19, Math.max(14, containerWidth * 0.017)); + fontStyle = "italic"; + break; + case "citation": + fontSizePx = Math.min(13, Math.max(10, containerWidth * 0.012)); + fontStyle = "italic"; + break; + case "process": + fontSizePx = Math.min(18, Math.max(13, containerWidth * 0.016)); + fontWeight = 600; + break; + default: + if (variant === "kicker") { + fontSizePx = 12; + fontWeight = 600; + letterSpacing = 0.14; + } else if (variant === "meta") { + fontSizePx = 13; + opacity = 0.75; + } else if (variant === "caption") { + fontSizePx = 13; + fontWeight = 600; + } + break; + } + + return { + fontFamily, + fontSizePx, + fontWeight, + fontStyle, + color: normalizeColor(theme.tokens.foreground), + lineHeight, + letterSpacing, + align, + verticalAlign, + opacity, + }; +} + +/** + * Theme-level chart style base (no per-bar series colors yet). + * Both the browser chart and the PPTX exporter derive their exact hexadecimal + * values from this single source. + */ +function resolveChartStyleBase(theme: ResolvedTheme): Omit<ResolvedChartStyle, "seriesColors"> { + return { + accentColor: normalizeColor(theme.chartPalette[0] ?? theme.tokens.foreground), + highlightColor: normalizeColor(theme.tokens.secondary), + foreground: normalizeColor(theme.tokens.foreground), + labelColor: normalizeColor(theme.tokens.muted), + axisColor: normalizeColor(theme.tokens.border), + gridColor: normalizeColor(theme.tokens.border), + fontFamily: theme.typography.bodyFont, + fontSize: 10, + background: "transparent", + }; +} + +/** + * Resolve the canonical, immutable chart spec for a chart block. + * + * THE single source of truth for chart data + style. Web, Present and the PPTX + * exporter all consume this exact spec; nobody reconstructs chart data or + * colors independently. Returns undefined for template charts ("New chart"), + * hidden blocks and charts without real data — those are never exported. + */ +export function resolveChartSpecForBlock( + deck: DeckProject, + block: Block, +): ResolvedChartSpec | undefined { + if (block.type !== "chart") return undefined; + const content = block.content as ChartContent | undefined; + if (!content || content.isTemplate) return undefined; + if (!Array.isArray(content.values) || content.values.length === 0) return undefined; + + const theme = resolveTheme(deck); + const base = resolveChartStyleBase(theme); + + // Per-bar colors: every bar gets the accent color except the highlighted one, + // which gets the theme's secondary/highlight color. This is what the browser + // draws and what PPTX must receive verbatim. + const seriesColors = content.values.map((_: ChartValue, index: number) => + index === content.highlightIndex ? base.highlightColor : base.accentColor, + ); + + return { + type: content.type ?? "bar", + orientation: content.type === "bar-horizontal" ? "horizontal" : "vertical", + title: content.title ?? "", + unit: content.unit ?? "", + categories: content.values.map((v: ChartValue) => v.label), + series: [ + { + name: content.title ?? "Data", + values: content.values.map((v: ChartValue) => v.value), + }, + ], + highlightIndex: content.highlightIndex, + summary: content.summary ?? "", + style: { ...base, seriesColors }, + }; +} + +// ─── Main Resolver ─────────────────────────────────────────────────────────── + +/** + * Resolve a slide into an immutable snapshot. + * This is the single source of truth for all renderers. + * + * When `assetRegistry` is supplied (the prepared export), image blocks carry a + * registry-aware `assetSnapshot` with the resolved data URI and a concrete + * ready/failed status — including orphans (block references a manifest entry + * that does not exist). Without a registry (web/presenter rendering) the + * snapshot is purely declarative. + */ +export function resolveSlideSnapshot( + slide: DeckSlide, + deck: DeckProject, + assetRegistry?: ReadonlyMap<string, PreparedAsset> +): ImmutableSlideSnapshot { + const theme = resolveTheme(deck); + const canvas = deck.canvas ?? { width: 1600, height: 900 }; + const width = canvas.width ?? 1600; + const height = canvas.height ?? 900; + + // Resolve geometry for all blocks + const geometryScene = resolveSlideGeometry(slide, canvas); + const frameByBlockId = geometryScene.frameByBlockId; + + // Build block snapshots + const blocks: ResolvedBlockSnapshot[] = []; + let zIndex = 0; + + for (const block of slide.blocks) { + // Skip hidden/deleted blocks + if (block.hidden) continue; + + // Get resolved frame + const resolvedFrame = frameByBlockId.get(block.id); + if (!resolvedFrame) continue; + + // Resolve style + const containerWidth = resolvedFrame.w; + const style = resolveBlockTextStyle(block, theme, containerWidth); + + // Resolve chart spec if applicable — canonical single source of truth + let chartSpec: ResolvedChartSpec | undefined; + if (block.type === "chart") { + chartSpec = resolveChartSpecForBlock(deck, block); + } + + // Resolve asset snapshot if applicable — the canonical asset reference is + // the single source of truth for which source the block needs embedded. + let assetSnapshot: ResolvedAssetSnapshot | undefined; + if (block.type === "image") { + const content = (block.content as ImageBlockContent | undefined) ?? {}; + const ref = canonicalAssetRef(deck, block); + const registryEntry = ref ? assetRegistry?.get(ref.assetId) : undefined; + const manifestAsset = + ref && !ref.assetId.startsWith("inline:") + ? resolveAsset(deck, ref.assetId) + : undefined; + + if (ref && ref.orphan) { + assetSnapshot = { + assetId: ref.assetId, + resolvedSrc: "", + dataUri: "", + status: "failed", + error: `Asset "${ref.assetId}" has no manifest entry`, + mimeType: "image/png", + width: 720, + height: 480, + alt: content.alt ?? block.alt ?? "", + fit: content.fit ?? "cover", + focalPoint: content.focalPoint ?? { x: 0.5, y: 0.5 }, + caption: content.caption, + attribution: content.attribution, + }; + } else if (ref) { + assetSnapshot = { + assetId: ref.assetId, + resolvedSrc: registryEntry?.originalSrc ?? ref.src ?? manifestAsset?.src ?? "", + dataUri: registryEntry?.resolvedDataUri, + status: registryEntry?.status ?? "ready", + error: registryEntry?.error, + mimeType: registryEntry?.mimeType ?? manifestAsset?.mimeType ?? "image/jpeg", + width: registryEntry?.width ?? manifestAsset?.width ?? 720, + height: registryEntry?.height ?? manifestAsset?.height ?? 480, + alt: content.alt ?? block.alt ?? manifestAsset?.alt ?? "", + fit: content.fit ?? "cover", + focalPoint: content.focalPoint ?? manifestAsset?.focalPoint ?? { x: 0.5, y: 0.5 }, + caption: content.caption, + attribution: content.attribution ?? manifestAsset?.credit, + }; + } + } + + blocks.push({ + id: block.id, + type: block.type, + frame: { + x: resolvedFrame.x, + y: resolvedFrame.y, + w: resolvedFrame.w, + h: resolvedFrame.h, + }, + zIndex: zIndex++, + visibility: "visible", + content: block.content, + style, + chartSpec, + assetSnapshot, + editorOnly: false, + deleted: false, + temporary: false, + placeholder: false, + }); + } + + // Resolve theme snapshot + const themeSnapshot: ResolvedThemeSnapshot = { + id: theme.id, + tokens: { ...theme.tokens }, + typography: { ...theme.typography }, + chartPalette: [...theme.chartPalette], + gradients: { ...(theme.gradients ?? {}) }, + }; + + // Resolve asset snapshots (deck-level manifest assets, registry-aware). + const assets: ResolvedAssetSnapshot[] = (deck.assets ?? []) + .filter((asset) => asset.status !== "failed") + .map((asset) => { + const entry = assetRegistry?.get(asset.id); + return { + assetId: asset.id, + resolvedSrc: asset.src, + dataUri: entry?.resolvedDataUri, + status: entry?.status, + error: entry?.error, + mimeType: asset.mimeType ?? "image/jpeg", + width: asset.width ?? 720, + height: asset.height ?? 480, + alt: asset.alt ?? "", + fit: "cover" as const, + focalPoint: asset.focalPoint ?? { x: 0.5, y: 0.5 }, + }; + }); + + return { + slideId: slide.id, + title: slide.title, + width, + height, + background: { + type: "solid", + color: normalizeColor(theme.tokens.background), + }, + blocks, + theme: themeSnapshot, + assets, + notes: slide.speakerNotes, + layout: slide.layout, + layoutBindings: slide.layoutBindings ?? [], + }; +} + +/** + * Create immutable snapshots for all slides in a deck. + * This is called once at export time and provides the snapshot for all renderers. + * When `assetRegistry` is supplied (prepared export), snapshots carry the + * resolved data URIs and ready/failed asset status. + */ +export function createDeckSnapshot( + deck: DeckProject, + assetRegistry?: ReadonlyMap<string, PreparedAsset> +): ImmutableSlideSnapshot[] { + return deck.slides.map((slide) => resolveSlideSnapshot(slide, deck, assetRegistry)); +} + +/** + * Validate that a snapshot contains no hidden/stale/template blocks. + */ +export function validateSnapshot(snapshot: ImmutableSlideSnapshot): string[] { + const issues: string[] = []; + + for (const block of snapshot.blocks) { + if (block.visibility === "hidden") { + issues.push(`Block ${block.id} is hidden but included in snapshot`); + } + if (block.editorOnly) { + issues.push(`Block ${block.id} is editor-only but included in snapshot`); + } + if (block.deleted) { + issues.push(`Block ${block.id} is deleted but included in snapshot`); + } + if (block.temporary) { + issues.push(`Block ${block.id} is temporary but included in snapshot`); + } + if (block.placeholder) { + issues.push(`Block ${block.id} is placeholder but included in snapshot`); + } + + // Validate chart blocks have required data + if (block.type === "chart") { + if (!block.chartSpec) { + issues.push(`Chart block ${block.id} has no resolved chart spec`); + } else if (block.chartSpec.categories.length === 0) { + issues.push(`Chart block ${block.id} has no categories`); + } + } + } + + return issues; +} + +/** + * Compute a semantic content fingerprint for a snapshot. + * Used for parity validation between web and export. + */ +export function hashSlideSemanticContent(snapshot: ImmutableSlideSnapshot): string { + const parts: string[] = []; + + // Add slide ID and title + parts.push(`slide:${snapshot.slideId}`); + parts.push(`title:${snapshot.title}`); + + // Add visible blocks in order + for (const block of snapshot.blocks) { + if (block.visibility !== "visible") continue; + + parts.push(`block:${block.id}:${block.type}`); + + // Add text content + if (typeof block.content === "string") { + parts.push(`text:${block.content}`); + } else if (Array.isArray(block.content)) { + parts.push(`list:${block.content.join("|")}`); + } else if (block.content && typeof block.content === "object") { + const content = block.content as Record<string, unknown>; + if (content.title) parts.push(`title:${content.title}`); + if (content.value) parts.push(`value:${content.value}`); + if (content.label) parts.push(`label:${content.label}`); + if (Array.isArray(content.values)) { + const values = content.values as Array<{ label: string; value: number }>; + parts.push(`values:${values.map((v) => `${v.label}:${v.value}`).join("|")}`); + } + } + + // Add chart spec if present + if (block.chartSpec) { + parts.push(`chart:${block.chartSpec.type}`); + parts.push(`categories:${block.chartSpec.categories.join("|")}`); + parts.push(`series:${block.chartSpec.series.map((s) => s.values.join(",")).join("|")}`); + } + + // Add asset if present + if (block.assetSnapshot) { + parts.push(`asset:${block.assetSnapshot.assetId}`); + } + } + + return parts.join("::"); +} From 4dbc2a1d02bdd62858d09a7bbdfbe9332144fc0c Mon Sep 17 00:00:00 2001 From: tph-kds <hungcompo123@gmail.com> Date: Sat, 15 Aug 2026 03:48:58 +0700 Subject: [PATCH 04/16] feat(scaffold): replace 21 drifted export files with 02-example versions --- .../export/export-dialog.tsx | 499 ++++++++++++--- .../export/export-preflight.ts | 578 ++++++++++++++---- .../starter-components/export/export-types.ts | 116 +++- .../export/fidelity/content-parity.ts | 47 +- .../export/pptx/block-exporters/chart.ts | 226 +++++-- .../export/pptx/block-exporters/diagram.ts | 60 +- .../export/pptx/block-exporters/fallback.ts | 37 +- .../export/pptx/block-exporters/image.ts | 251 +++++--- .../export/pptx/block-exporters/index.ts | 2 +- .../export/pptx/block-exporters/shape.ts | 36 +- .../export/pptx/block-exporters/table.ts | 36 +- .../export/pptx/block-exporters/text.ts | 464 ++++++++++---- .../export/pptx/block-exporters/video.ts | 45 +- .../export/pptx/pptx-assets.ts | 59 +- .../export/pptx/pptx-context.ts | 44 +- .../export/pptx/pptx-exporter.ts | 411 +++++++++---- .../export/pptx/pptx-fallback-renderer.ts | 11 +- .../export/pptx/pptx-fonts.ts | 32 +- .../export/pptx/pptx-theme.ts | 14 +- .../export/pptx/pptx-verifier.ts | 86 ++- 20 files changed, 2299 insertions(+), 755 deletions(-) diff --git a/skills/deckforge/starter-components/export/export-dialog.tsx b/skills/deckforge/starter-components/export/export-dialog.tsx index 3e7eb35..d271b98 100644 --- a/skills/deckforge/starter-components/export/export-dialog.tsx +++ b/skills/deckforge/starter-components/export/export-dialog.tsx @@ -3,10 +3,15 @@ import type { ExportPreflightResult, ExportReport, PptxExportConfig, + PreflightGroupSummary, } from "./export-types"; import { DEFAULT_PPTX_CONFIG } from "./export-types"; import { runExportPreflight } from "./export-preflight"; -import type { DeckProject } from "../deck-types"; +import { prepareExport, type PreparedExport } from "./prepare-export"; +import type { DeckProject, SaveState } from "../deck/types"; +import { makeDeckSelfContained } from "./self-contained"; +import { canonicalAssetRef } from "../deck/assets"; +import type { Command, DispatchResult } from "../deck/commands"; interface ExportDialogProps { deck: DeckProject; @@ -14,8 +19,33 @@ interface ExportDialogProps { onClose: () => void; onExport?: (result: Blob) => void; onError?: (error: Error) => void; + commit?: (command: Command) => DispatchResult | undefined; + saveNow?: (deck: DeckProject) => SaveState; } +/** + * Export dialog state machine (regression fix P2-003). + * + * The previous implementation kept a free-form `phase` alongside a heuristic + * score, so the UI could show "Ready to export" (from a geometry-unaware + * preflight) at the same time as "Export failed" (from the last real export), + * and repeated "Export failed" text when both the status line and the report + * box rendered. This version uses explicit, mutually exclusive states: + * + * IDLE → PREFLIGHTING → READY ─┐ + * │ ├─→ EXPORTING → SUCCESS + * ├→ BLOCKED └──────────────┘ + * └→ FAILED ←──────────────────────┘ + * + * READY is only reachable when preflight passes (no error issues and zero + * missing geometry); a failed preflight lands in BLOCKED (the export button + * is disabled), and a serialization failure lands in FAILED — the two are + * distinct states so "Export blocked" never shows the "FAILED" badge and the + * contradictory messages can never coexist. + */ +type ExportUiState = "idle" | "preflight" | "blocked" | "ready" | "exporting" | "success" | "failed"; +type ExportStage = "building" | "writing"; + function fidelitySummary(report: ExportReport): string { const fallbacks = report.slides.reduce( (total, slide) => @@ -35,34 +65,137 @@ function fidelitySummary(report: ExportReport): string { return `Native ${native} · Fallbacks ${fallbacks} · Missing ${missing}`; } -export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: ExportDialogProps) { +function currentImageSource(deck: DeckProject, slideId: string, blockId: string): string { + const slide = deck.slides.find((s) => s.id === slideId); + const block = slide?.blocks.find((b) => b.id === blockId); + if (!block) return ""; + return canonicalAssetRef(deck, block)?.src ?? ""; +} + +function fileToDataUri(file: File): Promise<string> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(reader.error ?? new Error("Could not read file")); + reader.readAsDataURL(file); + }); +} + +export function ExportDialog({ deck, isOpen, onClose, onExport, onError, commit, saveNow }: ExportDialogProps) { const [config, setConfig] = useState<PptxExportConfig>(DEFAULT_PPTX_CONFIG); const [preflight, setPreflight] = useState<ExportPreflightResult | null>(null); const [lastReport, setLastReport] = useState<ExportReport | null>(null); - const [isExporting, setIsExporting] = useState(false); + const [state, setState] = useState<ExportUiState>("idle"); + const [stage, setStage] = useState<ExportStage>("building"); + const [errorMessage, setErrorMessage] = useState<string>(""); + const [progress, setProgress] = useState(0); const [showDetails, setShowDetails] = useState(false); + const [fixDrafts, setFixDrafts] = useState<Record<string, string>>({}); + const [selfContaining, setSelfContaining] = useState(false); const dialogRef = useRef<HTMLDivElement>(null); const closeButtonRef = useRef<HTMLButtonElement>(null); + /** + * The single prepared export for the current deck+config. Preflight and the + * PPTX exporter MUST consume the SAME prepared result so "Ready to export" + * can never diverge from what the exporter will actually produce. Recreated + * whenever the deck or config changes. + */ + const preparedRef = useRef<PreparedExport | null>(null); const runPreflight = useCallback(async () => { if (!deck) return; - const result = await runExportPreflight(deck, config); - setPreflight(result); + setState("preflight"); + setErrorMessage(""); + try { + const prepared = await prepareExport(deck, config); + preparedRef.current = prepared; + const result = await runExportPreflight(prepared); + setPreflight(result); + setState(result.ready ? "ready" : "blocked"); + if (!result.ready) { + const errors = result.issues.filter((issue) => issue.severity === "error"); + setErrorMessage( + errors.length > 0 + ? `Preflight found ${errors.length} issue(s) that block a lossless export. Resolve them before exporting.` + : "Preflight found content that cannot be preserved. Resolve it before exporting.", + ); + } + } catch (error) { + setState("failed"); + setErrorMessage( + `Preflight analysis failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } }, [deck, config]); + const applyImageFix = (issue: { slideId?: string; blockId?: string }) => { + if (!issue.slideId || !issue.blockId || !commit) return; + const src = (fixDrafts[issue.blockId] ?? currentImageSource(deck, issue.slideId, issue.blockId)).trim(); + if (!src) return; + commit({ type: "updateImageSource", slideId: issue.slideId, blockId: issue.blockId, src }); + }; + + const chooseFileFix = async (issue: { slideId?: string; blockId?: string }) => { + if (!issue.slideId || !issue.blockId || !commit) return; + const input = document.getElementById(`fix-file-${issue.blockId}`) as HTMLInputElement | null; + const file = input?.files?.[0]; + if (!file) return; + const uri = await fileToDataUri(file); + setFixDrafts((d) => ({ ...d, [issue.blockId!]: uri })); + commit({ type: "updateImageSource", slideId: issue.slideId, blockId: issue.blockId, src: uri }); + }; + + const handleSelfContained = async () => { + if (!deck || !commit || selfContaining) return; + setSelfContaining(true); + setErrorMessage(""); + try { + const result = await makeDeckSelfContained(deck); + commit({ type: "replaceDeck", deck: result.deck }); + saveNow?.(result.deck); + if (result.failures.length > 0) { + setErrorMessage( + `${result.failures.length} image(s) could not be embedded offline. ` + + "Resolve the remaining issues to export.", + ); + } + } catch (error) { + setErrorMessage( + `Could not make the deck self-contained: ${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + setSelfContaining(false); + } + }; + useEffect(() => { if (isOpen) { + setLastReport(null); + setErrorMessage(""); + setProgress(0); + setState("preflight"); runPreflight(); closeButtonRef.current?.focus(); } }, [isOpen, runPreflight]); + // Re-run preflight whenever the configuration changes so "Ready to export" + // always reflects the actual config (e.g. speaker notes toggled on/off). + useEffect(() => { + if (isOpen && state !== "exporting") { + runPreflight(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config]); + useEffect(() => { if (!isOpen) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { + if (state === "exporting") return; onClose(); + return; } if (e.key === "Tab" && dialogRef.current) { @@ -84,26 +217,40 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [isOpen, onClose]); + }, [isOpen, onClose, state]); const handleExport = async () => { if (!deck) return; - setIsExporting(true); + setState("exporting"); + setStage("building"); + setErrorMessage(""); + setProgress(10); try { const { PptxExporter } = await import("./pptx/pptx-exporter"); + setProgress(30); const exporter = new PptxExporter(config); - const result = await exporter.export(deck); + setStage("writing"); + setProgress(50); + // Reuse the SAME prepared export that preflight consumed — the exporter + // must never re-resolve assets or it could disagree with the READY + // verdict (regression: preflight "Ready" + export "Failed to resolve"). + const prepared = preparedRef.current ?? (await prepareExport(deck, config)); + preparedRef.current = prepared; + const result = await exporter.export(prepared); + setProgress(90); setLastReport(result.report); if (result.report.status === "failed") { - onError?.( - new Error( - "Export failed: content could not be fully preserved. Fix the missing content before downloading.", - ), + setState("failed"); + setErrorMessage( + "Export failed: content could not be fully preserved. Fix the missing content before downloading.", ); + onError?.(new Error(errorMessage)); return; } + setProgress(100); + setState("success"); onExport?.(result.blob); const deckData = deck as { meta?: { title?: string } }; @@ -117,17 +264,21 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor const a = document.createElement("a"); a.href = url; a.download = filename; + document.body.appendChild(a); a.click(); + document.body.removeChild(a); URL.revokeObjectURL(url); } catch (error) { + setState("failed"); + const message = error instanceof Error ? error.message : String(error); + setErrorMessage(`Export failed: ${message}`); onError?.(error as Error); - } finally { - setIsExporting(false); } }; if (!isOpen) return null; + const isExporting = state === "exporting"; const scoreColor = (preflight?.score ?? 0) >= 80 ? "var(--theme-secondary, #10b981)" @@ -135,12 +286,34 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor ? "#f59e0b" : "var(--ui-danger, #dc2626)"; - const reportColor = - lastReport?.status === "complete" - ? "var(--theme-secondary, #10b981)" - : lastReport?.status === "partial" - ? "#f59e0b" - : "var(--ui-danger, #dc2626)"; + const canExport = (state === "ready" || state === "success") && !isExporting; + + const FIXABLE_IMAGE_CODES = new Set(["unresolved-image", "image-load-failed", "unknown-asset"]); + const fixableIssues = + preflight?.issues.filter( + (i) => FIXABLE_IMAGE_CODES.has(i.code) && Boolean(i.slideId) && Boolean(i.blockId), + ) ?? []; + + const stageLabel: Record<ExportStage, string> = { + building: "Building slides...", + writing: "Writing PPTX...", + }; + + const renderIssues = (issues: Array<{ severity: string; message: string; suggestedFix?: string }>) => ( + <div style={{ fontSize: 12, color: "var(--ui-muted)", maxHeight: 140, overflowY: "auto" }}> + {issues.slice(0, 8).map((issue, idx) => ( + <div key={idx} style={{ marginBottom: 3 }}> + <span style={{ color: issue.severity === "error" ? "var(--ui-danger)" : issue.severity === "warning" ? "#b45309" : "var(--ui-muted)", fontWeight: 600 }}> + {issue.severity} + </span>{" "} + {issue.message} + </div> + ))} + {issues.length > 8 && ( + <div style={{ marginTop: 4 }}>… {issues.length - 8} more issue(s)</div> + )} + </div> + ); return ( <div @@ -148,7 +321,7 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor role="dialog" aria-modal="true" aria-labelledby="export-dialog-title" - onClick={(e) => { if (e.target === e.currentTarget) onClose(); }} + onClick={(e) => { if (e.target === e.currentTarget && !isExporting) onClose(); }} > <div className="dialog" ref={dialogRef} style={{ maxWidth: 480 }}> <div className="dialog-header"> @@ -158,6 +331,7 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor className="icon-button" onClick={onClose} aria-label="Close export dialog" + disabled={isExporting} > × </button> @@ -169,13 +343,50 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor <select value={config.mode} onChange={(e) => setConfig({ ...config, mode: e.target.value as PptxExportConfig["mode"] })} + disabled={isExporting} > <option value="fidelity-first">PPTX (Fidelity First)</option> <option value="editability-first">PPTX (Editability First)</option> </select> </label> - {preflight && ( + {state === "preflight" && ( + <div role="status" aria-live="polite" style={{ marginBottom: 14 }}> + <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}> + <span className="spinner" aria-hidden="true" style={{ + width: 14, height: 14, border: "2px solid var(--ui-border)", + borderTopColor: "var(--ui-fg)", borderRadius: "50%", + display: "inline-block", animation: "spin 0.7s linear infinite", + }} /> + <span style={{ fontWeight: 600, fontSize: 13, color: "var(--ui-fg)" }}> + Analyzing deck... + </span> + </div> + </div> + )} + + {isExporting && ( + <div role="status" aria-live="polite" style={{ marginBottom: 14 }}> + <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}> + <span className="spinner" aria-hidden="true" style={{ + width: 14, height: 14, border: "2px solid var(--ui-border)", + borderTopColor: "var(--ui-fg)", borderRadius: "50%", + display: "inline-block", animation: "spin 0.7s linear infinite", + }} /> + <span style={{ fontWeight: 600, fontSize: 13, color: "var(--ui-fg)" }}> + {stageLabel[stage]} + </span> + </div> + <div style={{ height: 4, borderRadius: 2, background: "var(--ui-border)", overflow: "hidden" }}> + <div style={{ + height: "100%", width: `${progress}%`, borderRadius: 2, + background: "var(--ui-fg)", transition: "width 0.3s ease", + }} /> + </div> + </div> + )} + + {state === "ready" && preflight && ( <div role="status" aria-live="polite" @@ -189,66 +400,160 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor > <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 6 }}> <span style={{ fontWeight: 600, fontSize: 13, color: "var(--ui-fg)" }}> - {(preflight.score ?? 0) >= 80 ? "Ready to export" : (preflight.score ?? 0) >= 50 ? "Export with warnings" : "Issues detected"} + Ready to export </span> <span style={{ fontFamily: "var(--font-code)", fontSize: 12, color: scoreColor, fontWeight: 600 }}> {preflight.score}/100 </span> </div> - <div style={{ display: "flex", gap: 12, fontSize: 12, color: "var(--ui-muted)" }}> + <div style={{ display: "flex", gap: 12, fontSize: 12, color: "var(--ui-muted)", flexWrap: "wrap" }}> <span>Coverage {Math.round(preflight.blockCoverage * 100)}%</span> <span>Recall {Math.round((preflight.estimatedRecall ?? 1) * 100)}%</span> - <span>{preflight.estimatedFallbacks ?? 0} fallbacks</span> - <span>{(preflight.estimatedMissing ?? 0) > 0 ? `${preflight.estimatedMissing} missing` : "0 missing"}</span> - <span>{preflight.issues.filter(i => i.severity === "warning").length} warnings</span> - <span>{preflight.issues.filter(i => i.severity === "info").length} info</span> + <span>Native {preflight.coverage.native}</span> + <span>Fallbacks {preflight.coverage.fallback}</span> + <span>Missing {preflight.coverage.missing}</span> + {preflight.coverage.satisfied && ( + <span style={{ color: "var(--theme-secondary, #10b981)", fontWeight: 600 }}> + invariants OK + </span> + )} </div> </div> )} - {lastReport && ( + {(state === "failed" || state === "blocked") && ( <div - role="status" + role="alert" aria-live="polite" style={{ padding: "12px 14px", borderRadius: "var(--ui-radius)", - border: `1px solid ${reportColor}33`, - backgroundColor: `${reportColor}0A`, + border: `1px solid ${state === "failed" ? "var(--ui-danger, #dc2626)" : "var(--ui-danger, #dc2626)"}33`, + backgroundColor: "var(--ui-danger, #dc2626)0A", marginBottom: 14, }} > <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 6 }}> + <span style={{ fontWeight: 600, fontSize: 13, color: "var(--ui-danger, #dc2626)" }}> + {state === "failed" ? "Export failed" : "Export blocked"} + </span> + <span style={{ fontFamily: "var(--font-code)", fontSize: 12, color: "var(--ui-danger, #dc2626)", fontWeight: 600, textTransform: "uppercase" }}> + {state === "failed" ? "FAILED" : "BLOCKED"} + </span> + </div> + <div style={{ fontSize: 12, color: "var(--ui-muted)", marginBottom: 4 }}> + {errorMessage} + </div> + </div> + )} + + {state === "blocked" && fixableIssues.length > 0 && ( + <div style={{ marginBottom: 14 }}> + {fixableIssues.map((issue) => { + const blockId = issue.blockId!; + const value = fixDrafts[blockId] ?? currentImageSource(deck, issue.slideId!, blockId); + return ( + <div + key={`${issue.slideId}:${blockId}`} + style={{ + display: "flex", + flexDirection: "column", + gap: 6, + padding: "10px 12px", + borderRadius: "var(--ui-radius)", + border: "1px solid var(--ui-border)", + marginBottom: 8, + background: "var(--ui-surface)", + }} + > + <div style={{ fontSize: 12, fontWeight: 600, color: "var(--ui-fg)" }}> + Fix image {blockId} + </div> + <input + type="text" + aria-label={`Image source for ${blockId}`} + value={value} + onChange={(e) => setFixDrafts((d) => ({ ...d, [blockId]: e.target.value }))} + disabled={isExporting} + placeholder="https://… or data:image/…" + style={{ + padding: "6px 8px", + borderRadius: "var(--ui-radius)", + border: "1px solid var(--ui-border)", + background: "var(--ui-bg)", + fontSize: 12, + color: "var(--ui-fg)", + }} + /> + <div style={{ display: "flex", gap: 12, alignItems: "center" }}> + <label + htmlFor={`fix-file-${blockId}`} + style={{ fontSize: 12, cursor: "pointer", color: "var(--ui-fg)" }} + > + Choose file… + </label> + <input + id={`fix-file-${blockId}`} + type="file" + accept="image/*" + style={{ display: "none" }} + onChange={() => chooseFileFix(issue)} + /> + <button + onClick={() => applyImageFix(issue)} + aria-label={`Apply image fix for ${blockId}`} + disabled={isExporting} + style={{ + marginLeft: "auto", + padding: "5px 12px", + borderRadius: "var(--ui-radius)", + border: "none", + background: "var(--ui-fg)", + color: "#fff", + fontSize: 12, + fontWeight: 600, + cursor: isExporting ? "not-allowed" : "pointer", + }} + > + Apply + </button> + </div> + </div> + ); + })} + </div> + )} + + {state === "success" && lastReport && ( + <div + role="status" + aria-live="polite" + style={{ + padding: "12px 14px", + borderRadius: "var(--ui-radius)", + border: "1px solid var(--theme-secondary, #10b981)33", + backgroundColor: "var(--theme-secondary, #10b981)0A", + marginBottom: 14, + }} + > + <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}> + <span style={{ color: "var(--theme-secondary, #10b981)", fontSize: 16 }} aria-hidden="true">✓</span> <span style={{ fontWeight: 600, fontSize: 13, color: "var(--ui-fg)" }}> - Export {lastReport.status} + Export complete! </span> - <span style={{ fontFamily: "var(--font-code)", fontSize: 12, color: reportColor, fontWeight: 600, textTransform: "uppercase" }}> + <span style={{ fontFamily: "var(--font-code)", fontSize: 12, color: "var(--theme-secondary, #10b981)", fontWeight: 600, textTransform: "uppercase", marginLeft: "auto" }}> {lastReport.status} </span> </div> <div style={{ fontSize: 12, color: "var(--ui-muted)", marginBottom: 4 }}> {fidelitySummary(lastReport)} </div> - {lastReport.status === "failed" && ( - <div style={{ marginTop: 8, fontSize: 12, color: "var(--ui-danger, #dc2626)", fontWeight: 600 }}> - Export blocked: content was not fully preserved. Review the missing blocks below. - </div> - )} - {lastReport.issues.length > 0 && ( - <div style={{ fontSize: 12, color: "var(--ui-muted)", maxHeight: 120, overflowY: "auto" }}> - {lastReport.issues.slice(0, 8).map((issue, idx) => ( - <div key={idx} style={{ marginBottom: 3 }}> - <span style={{ color: issue.severity === "error" ? "var(--ui-danger)" : issue.severity === "warning" ? "#b45309" : "var(--ui-muted)", fontWeight: 600 }}> - {issue.severity} - </span>{" "} - {issue.message} - </div> - ))} - {lastReport.issues.length > 8 && ( - <div style={{ marginTop: 4 }}>… {lastReport.issues.length - 8} more issue(s)</div> - )} - </div> - )} + </div> + )} + + {lastReport && lastReport.issues.length > 0 && state !== "exporting" && ( + <div style={{ fontSize: 12, color: "var(--ui-muted)", marginBottom: 14 }}> + {renderIssues(lastReport.issues)} </div> )} @@ -257,21 +562,32 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor type="checkbox" checked={config.includeSpeakerNotes} onChange={(e) => setConfig({ ...config, includeSpeakerNotes: e.target.checked })} + disabled={isExporting} /> Include speaker notes </label> <div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}> + <button + className="text-button" + onClick={handleSelfContained} + disabled={isExporting || selfContaining} + style={{ fontSize: 12, marginRight: "auto" }} + > + {selfContaining ? "Embedding images…" : "Make deck self-contained"} + </button> <button className="text-button" onClick={() => setShowDetails(!showDetails)} aria-expanded={showDetails} style={{ fontSize: 12 }} + disabled={isExporting || !preflight} > {showDetails ? "Hide details" : "View details"} </button> <button onClick={onClose} + disabled={isExporting} style={{ padding: "6px 14px", borderRadius: "var(--ui-radius)", @@ -281,30 +597,29 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor fontWeight: 500, }} > - Cancel + {state === "success" ? "Close" : "Cancel"} </button> <button onClick={handleExport} - disabled={isExporting || (preflight?.score ?? 0) < 20 || lastReport?.status === "failed"} + disabled={!canExport} aria-busy={isExporting} style={{ padding: "6px 14px", borderRadius: "var(--ui-radius)", border: "none", - background: "var(--ui-fg)", - color: "#fff", + background: canExport ? "var(--ui-fg)" : "var(--ui-border)", + color: canExport ? "#fff" : "var(--ui-muted)", fontSize: 13, fontWeight: 600, - cursor: isExporting ? "not-allowed" : "pointer", - opacity: isExporting || (preflight?.score ?? 0) < 20 ? 0.5 : 1, + cursor: canExport ? "pointer" : "not-allowed", }} > - {isExporting ? "Exporting..." : "Export PPTX"} + {isExporting ? "Exporting..." : state === "success" ? "Export Again" : "Export PPTX"} </button> </div> </div> - {showDetails && preflight && ( + {showDetails && preflight && state !== "exporting" && ( <div style={{ borderTop: "1px solid var(--ui-border)", padding: "14px 18px" }}> <h3 style={{ fontSize: 12, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em", color: "var(--ui-muted)", margin: "0 0 10px" }}> Preflight Issues @@ -312,31 +627,43 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor {preflight.issues.length === 0 ? ( <p style={{ fontSize: 13, color: "var(--ui-muted)", margin: 0 }}>No issues found</p> ) : ( - <ul style={{ listStyle: "none", padding: 0, margin: 0 }}> - {preflight.issues.map((issue: { severity: string; message: string; suggestedFix?: string }, idx: number) => ( - <li - key={idx} - style={{ - padding: "8px 10px", - marginBottom: 4, - borderRadius: "var(--ui-radius)", - backgroundColor: issue.severity === "error" ? "#fef2f2" : issue.severity === "warning" ? "#fffbeb" : "var(--ui-surface)", - fontSize: 12, - lineHeight: 1.5, - }} - > - <span style={{ fontWeight: 600, color: issue.severity === "error" ? "var(--ui-danger)" : issue.severity === "warning" ? "#b45309" : "var(--ui-muted)" }}> - {issue.severity} - </span>{" "} - {issue.message} - {issue.suggestedFix && ( - <div style={{ marginTop: 3, color: "var(--ui-muted)", fontSize: 11 }}> - {issue.suggestedFix} - </div> + preflight.groups.map((group: PreflightGroupSummary) => ( + <div key={group.group} style={{ marginBottom: 10 }}> + <div style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em", color: "var(--ui-muted)", margin: "0 0 6px" }}> + {group.label} <span style={{ fontWeight: 400 }}>({group.count})</span> + </div> + <ul style={{ listStyle: "none", padding: 0, margin: 0 }}> + {group.issues.slice(0, 5).map((issue, idx) => ( + <li + key={idx} + style={{ + padding: "8px 10px", + marginBottom: 4, + borderRadius: "var(--ui-radius)", + backgroundColor: issue.severity === "error" ? "#fef2f2" : issue.severity === "warning" ? "#fffbeb" : "var(--ui-surface)", + fontSize: 12, + lineHeight: 1.5, + }} + > + <span style={{ fontWeight: 600, color: issue.severity === "error" ? "var(--ui-danger)" : issue.severity === "warning" ? "#b45309" : "var(--ui-muted)" }}> + {issue.severity} + </span>{" "} + {issue.message} + {issue.suggestedFix && ( + <div style={{ marginTop: 3, color: "var(--ui-muted)", fontSize: 11 }}> + {issue.suggestedFix} + </div> + )} + </li> + ))} + {group.issues.length > 5 && ( + <li style={{ fontSize: 11, color: "var(--ui-muted)", paddingLeft: 10 }}> + … {group.issues.length - 5} more + </li> )} - </li> - ))} - </ul> + </ul> + </div> + )) )} </div> )} diff --git a/skills/deckforge/starter-components/export/export-preflight.ts b/skills/deckforge/starter-components/export/export-preflight.ts index d4f88de..7f0213a 100644 --- a/skills/deckforge/starter-components/export/export-preflight.ts +++ b/skills/deckforge/starter-components/export/export-preflight.ts @@ -1,30 +1,33 @@ // export/export-preflight.ts +// +// Export preflight validation that ensures all blocks are properly resolved +// before export. This prevents: +// - Hidden/stale/template blocks from being exported +// - Missing chart data +// - Missing image assets +// - Geometry errors +// - Duplicate block exports +// +// Preflight operates on the output of the single `prepareExport` phase: it +// consumes the prepared snapshots and the canonical asset registry, so +// "Ready to export" is only ever reported when every required visible image +// actually resolved to embeddable bytes. No network work happens here. +import type { DeckProject } from "../deck/types"; import type { - ExportPreflightResult, ExportIssue, + ExportPreflightResult, + ExportCoverage, + PreflightGroupSummary, PptxExportConfig, } from "./export-types"; -import type { DeckProject } from "../deck-types"; -import { collectFontWarnings } from "./pptx/pptx-fonts"; +import { DEFAULT_PPTX_CONFIG } from "./export-types"; +import type { ImmutableSlideSnapshot, ResolvedBlockSnapshot } from "./snapshot"; +import { hashSlideSemanticContent } from "./snapshot"; +import { prepareExport, isPreparedExport, type PreparedExport } from "./prepare-export"; import { getBlockExporter } from "./pptx/block-exporters/index"; -const NATIVE_BLOCK_TYPES = new Set([ - "text", - "heading", - "bullets", - "callout", - "citation", - "metric", - "image", - "shape", - "table", - "chart", -]); - -function asRecord(value: unknown): Record<string, unknown> { - return value as Record<string, unknown>; -} +// ─── Preflight Scoring ─────────────────────────────────────────────────────── function calculateScore(issues: ExportIssue[]): number { let score = 100; @@ -36,134 +39,485 @@ function calculateScore(issues: ExportIssue[]): number { return Math.max(0, Math.min(100, score)); } -function calculateBlockCoverage(deck: DeckProject): number { - const blocks = deck.slides.flatMap((slide) => slide.blocks); - if (blocks.length === 0) return 1; +// ─── Preflight Validators ──────────────────────────────────────────────────── - const nativeCount = blocks.filter((block) => NATIVE_BLOCK_TYPES.has(block.type)).length; - return nativeCount / blocks.length; -} +/** + * Validate that a snapshot contains no hidden/stale/template blocks. + */ +function validateBlockVisibility( + block: ResolvedBlockSnapshot, + slideId: string +): ExportIssue[] { + const issues: ExportIssue[] = []; -function calculateParityEstimates(deck: DeckProject): { - estimatedRecall: number; - estimatedFallbacks: number; - estimatedMissing: number; -} { - const visible = deck.slides - .filter((slide) => !slide.hidden) - .flatMap((slide) => slide.blocks) - .filter((block) => !block.hidden); - if (visible.length === 0) { - return { estimatedRecall: 1, estimatedFallbacks: 0, estimatedMissing: 0 }; - } - - let fallbacks = 0; - let missing = 0; - for (const block of visible) { - const exporter = getBlockExporter(block.type); - if (exporter.type === "fallback" && block.type !== "fallback") { - missing += 1; - } else if (exporter.exportability === "image-only") { - fallbacks += 1; - } + if (block.visibility === "hidden") { + issues.push({ + code: "block-hidden-skipped", + severity: "warning", + slideId, + blockId: block.id, + message: `Block "${block.id}" is hidden but included in snapshot`, + automaticFixAvailable: true, + }); } - const preserved = visible.length - missing; - return { - estimatedRecall: preserved / visible.length, - estimatedFallbacks: fallbacks, - estimatedMissing: missing, - }; + + if (block.editorOnly) { + issues.push({ + code: "block-hidden-skipped", + severity: "warning", + slideId, + blockId: block.id, + message: `Block "${block.id}" is editor-only but included in snapshot`, + automaticFixAvailable: true, + }); + } + + if (block.deleted) { + issues.push({ + code: "block-hidden-skipped", + severity: "warning", + slideId, + blockId: block.id, + message: `Block "${block.id}" is deleted but included in snapshot`, + automaticFixAvailable: true, + }); + } + + if (block.temporary) { + issues.push({ + code: "block-hidden-skipped", + severity: "warning", + slideId, + blockId: block.id, + message: `Block "${block.id}" is temporary but included in snapshot`, + automaticFixAvailable: true, + }); + } + + if (block.placeholder) { + issues.push({ + code: "block-hidden-skipped", + severity: "warning", + slideId, + blockId: block.id, + message: `Block "${block.id}" is placeholder but included in snapshot`, + automaticFixAvailable: true, + }); + } + + return issues; } -export async function runExportPreflight( - deck: DeckProject, - config: PptxExportConfig -): Promise<ExportPreflightResult> { +/** + * Validate chart blocks have required data. + */ +function validateChartBlock( + block: ResolvedBlockSnapshot, + slideId: string +): ExportIssue[] { const issues: ExportIssue[] = []; - const fontWarnings = collectFontWarnings(deck); - for (const fw of fontWarnings) { + if (block.type !== "chart") return issues; + + if (!block.chartSpec) { issues.push({ + code: "chart-no-data", severity: "warning", - code: "font-substitution", - slideId: fw.slideId, - blockId: fw.blockId, - message: `Font "${fw.fontFamily}" may be substituted with ${fw.substituteFont}`, - suggestedFix: `Use a PPTX-safe font like ${fw.substituteFont}`, + slideId, + blockId: block.id, + message: `Chart block "${block.id}" has no resolved chart spec`, + suggestedFix: "Add data values to the chart", automaticFixAvailable: false, }); + return issues; } - for (const slide of deck.slides) { - for (const block of slide.blocks) { - const record = asRecord(block); - const blockType = block.type; + if (block.chartSpec.categories.length === 0) { + issues.push({ + code: "chart-no-data", + severity: "warning", + slideId, + blockId: block.id, + message: `Chart block "${block.id}" has no categories`, + suggestedFix: "Add category labels to the chart", + automaticFixAvailable: false, + }); + } - if (!NATIVE_BLOCK_TYPES.has(blockType)) { - issues.push({ - severity: "warning", - code: "unsupported-block-type", - slideId: slide.id, + if (block.chartSpec.series.length === 0 || block.chartSpec.series[0].values.length === 0) { + issues.push({ + code: "chart-no-data", + severity: "warning", + slideId, + blockId: block.id, + message: `Chart block "${block.id}" has no series data`, + suggestedFix: "Add data values to the chart", + automaticFixAvailable: false, + }); + } + + return issues; +} + +/** + * Classify an image block against the resolved asset registry and report the + * issues that block (or constrain) an export. + * + * ready → native (resolved to embeddable bytes in preparation) + * failed → Fidelity First: missing + blocking error + * Editability First: fallback + warning (placeholder embedded) + * no snapshot → placeholder block (no source): fallback + info + */ +function classifyImageBlock( + block: ResolvedBlockSnapshot, + slideId: string, + config: PptxExportConfig, +): { representation: "native" | "fallback" | "missing"; issues: ExportIssue[] } { + const as = block.assetSnapshot; + + if (!as) { + return { + representation: "fallback", + issues: [ + { + code: "image-load-failed", + severity: "info", + slideId, blockId: block.id, - message: `Block type "${blockType}" cannot be exported natively; it will be rasterized, substituted, or omitted`, - suggestedFix: "Convert to a supported block type for native export", - automaticFixAvailable: false, - }); - } + message: `Image block "${block.id}" has no image source; a bundled placeholder raster will be embedded`, + suggestedFix: "Attach a local asset to the image block or use a data: URL", + automaticFixAvailable: true, + }, + ], + }; + } - if (typeof record.cssFilter === "string" && record.cssFilter.includes("blur")) { - issues.push({ - severity: "warning", - code: "unsupported-css-effect", - slideId: slide.id, + if (as.status === "ready") { + return { representation: "native", issues: [] }; + } + + const reason = + as.error ?? + (as.resolvedSrc ? `image "${as.resolvedSrc}" could not be resolved` : "no resolvable source"); + + if (config.mode === "fidelity-first") { + return { + representation: "missing", + issues: [ + { + code: "unresolved-image", + severity: "error", + slideId, blockId: block.id, - message: "CSS filter effects may not transfer to PowerPoint", - suggestedFix: "Remove blur filter or accept image fallback", + message: `Image block "${block.id}" cannot be embedded in the PPTX: ${reason}`, + suggestedFix: "Fix the image URL or attach a local/data: asset so the image can be embedded offline", automaticFixAvailable: false, - }); + }, + ], + }; + } + + return { + representation: "fallback", + issues: [ + { + code: "image-load-failed", + severity: "warning", + slideId, + blockId: block.id, + message: `Image block "${block.id}" cannot be embedded: ${reason}; a bundled placeholder raster will be embedded in its place`, + suggestedFix: "Fix the image URL or attach a local/data: asset so the image can be embedded offline", + automaticFixAvailable: false, + }, + ], + }; +} + +/** + * Validate geometry for all blocks. + */ +function validateBlockGeometry( + block: ResolvedBlockSnapshot, + slideId: string +): ExportIssue[] { + const issues: ExportIssue[] = []; + + if (!block.frame) { + issues.push({ + code: "invalid-geometry", + severity: "error", + slideId, + blockId: block.id, + message: `Block "${block.id}" has no geometry`, + automaticFixAvailable: false, + }); + return issues; + } + + const { x, y, w, h } = block.frame; + if (w <= 0 || h <= 0) { + issues.push({ + code: "invalid-geometry", + severity: "error", + slideId, + blockId: block.id, + message: `Block "${block.id}" has invalid dimensions (${w}x${h})`, + automaticFixAvailable: false, + }); + } + + return issues; +} + +/** + * Validate no duplicate block IDs in a snapshot. + */ +function validateNoDuplicateBlockIds( + snapshot: ImmutableSlideSnapshot +): ExportIssue[] { + const issues: ExportIssue[] = []; + const seenIds = new Set<string>(); + + for (const block of snapshot.blocks) { + if (seenIds.has(block.id)) { + issues.push({ + code: "duplicate-element-id", + severity: "warning", + slideId: snapshot.slideId, + blockId: block.id, + message: `Block "${block.id}" is duplicated in slide "${snapshot.slideId}"`, + automaticFixAvailable: false, + }); + } + seenIds.add(block.id); + } + + return issues; +} + +// ─── Main Preflight Function ───────────────────────────────────────────────── + +/** + * Run export preflight validation on a prepared export. + * + * Pass the result of `prepareExport` so the preflight, fidelity accounting and + * the PPTX exporter all reason about the SAME resolved assets. For backward + * compatibility a raw `DeckProject` is prepared on the fly (this still + * resolves assets exactly once, inside that preparation). + */ +export async function runExportPreflight( + input: PreparedExport | DeckProject, + config?: PptxExportConfig +): Promise<ExportPreflightResult> { + const prepared: PreparedExport = isPreparedExport(input) + ? input + : await prepareExport(input, config ?? DEFAULT_PPTX_CONFIG); + + const issues: ExportIssue[] = []; + + let chartBlockCount = 0; + let geometryMissingCount = 0; + + // Parity/coverage tallies over all visible blocks (fractions per contract). + let visibleCount = 0; + let nativeCount = 0; + let fallbackCount = 0; + let missingCount = 0; + + for (const snapshot of prepared.slides) { + const rawSlide = prepared.deck.slides.find((slide) => slide.id === snapshot.slideId); + if (!rawSlide) continue; + + // Validate each block that made it into the canonical snapshot. + for (const block of snapshot.blocks) { + visibleCount++; + + if (block.type === "chart") chartBlockCount++; + + issues.push(...validateBlockVisibility(block, snapshot.slideId)); + issues.push(...validateChartBlock(block, snapshot.slideId)); + issues.push(...validateBlockGeometry(block, snapshot.slideId)); + + if (block.type === "image") { + const classification = classifyImageBlock(block, snapshot.slideId, prepared.config); + issues.push(...classification.issues); + if (classification.representation === "native") nativeCount++; + else if (classification.representation === "fallback") fallbackCount++; + else missingCount++; + continue; } - if (typeof record.src === "string" && record.src.startsWith("http") && !record.src.startsWith("data:")) { + const exporter = getBlockExporter(block.type); + if (exporter.type === "fallback" && block.type !== "fallback") { issues.push({ - severity: "info", - code: "external-asset", - slideId: slide.id, + code: "unsupported-block-type", + severity: "warning", + slideId: snapshot.slideId, blockId: block.id, - message: "External asset will be embedded in the export", - suggestedFix: undefined, + message: `Block type "${block.type}" cannot be exported natively; it will be rasterized, substituted, or omitted`, + suggestedFix: "Convert to a supported block type for native export", automaticFixAvailable: false, }); + missingCount++; + continue; + } + if (exporter.exportability === "image-only") { + fallbackCount++; + } else { + nativeCount++; } } - if (config.includeSpeakerNotes && !slide.speakerNotes) { + issues.push(...validateNoDuplicateBlockIds(snapshot)); + + // Fail closed on geometry: any visible raw block missing from the canonical + // snapshot has no resolvable frame and cannot be exported. + const snapshotBlockIds = new Set(snapshot.blocks.map((block) => block.id)); + for (const block of rawSlide.blocks) { + if (block.hidden) continue; + if (snapshotBlockIds.has(block.id)) continue; + geometryMissingCount++; + visibleCount++; issues.push({ - severity: "info", - code: "missing-speaker-notes", - slideId: slide.id, - message: "Slide has no speaker notes", - suggestedFix: "Add speaker notes for better presenter experience", - automaticFixAvailable: false, + code: "invalid-geometry", + severity: "error", + slideId: rawSlide.id, + blockId: block.id, + message: `Block "${block.id}" (${block.type}) has no resolvable frame and cannot be exported`, + suggestedFix: "Bind the block to a layout slot or give it an explicit frame", + automaticFixAvailable: true, }); } } - const score = calculateScore(issues); - const blockCoverage = calculateBlockCoverage(deck); - const estimates = calculateParityEstimates(deck); + // Check for errors + const hasErrors = issues.some((issue) => issue.severity === "error"); - const visible = deck.slides - .filter((slide) => !slide.hidden) - .flatMap((slide) => slide.blocks) - .filter((block) => !block.hidden); + // Parity estimates are 0..1 fractions, not percentages (exported contract). + const estimatedMissing = missingCount; + const estimatedFallbacks = fallbackCount; + const estimatedRecall = + visibleCount > 0 ? (visibleCount - estimatedMissing) / visibleCount : 1; + + // Coverage invariants: expected == native + fallback and missing == 0. + const coverage: ExportCoverage = { + expected: visibleCount, + native: nativeCount, + fallback: fallbackCount, + missing: estimatedMissing + geometryMissingCount, + satisfied: estimatedMissing === 0 && geometryMissingCount === 0, + }; + + // Group issues by category + const groups: PreflightGroupSummary[] = [ + { + group: "geometry", + label: "Geometry", + count: issues.filter((i) => i.code === "invalid-geometry").length, + issues: issues.filter((i) => i.code === "invalid-geometry"), + }, + { + group: "assets", + label: "Assets", + count: issues.filter((i) => i.code === "unresolved-image" || i.code === "image-load-failed").length, + issues: issues.filter((i) => i.code === "unresolved-image" || i.code === "image-load-failed"), + }, + { + group: "content", + label: "Content", + count: issues.filter((i) => i.code === "chart-no-data").length, + issues: issues.filter((i) => i.code === "chart-no-data"), + }, + { + group: "structural", + label: "Structural", + count: issues.filter((i) => + i.code === "block-hidden-skipped" || + i.code === "duplicate-element-id" || + i.code === "unsupported-block-type" + ).length, + issues: issues.filter((i) => + i.code === "block-hidden-skipped" || + i.code === "duplicate-element-id" || + i.code === "unsupported-block-type" + ), + }, + ]; return { issues, - score, - blockCoverage, - ...estimates, - missingBlockCount: estimates.estimatedMissing, - unsupportedBlockCount: estimates.estimatedMissing, - chartBlockCount: visible.filter((block) => block.type === "chart").length, + score: calculateScore(issues), + blockCoverage: visibleCount > 0 ? nativeCount / visibleCount : 1, + estimatedFallbacks, + estimatedRecall, + estimatedMissing, + missingBlockCount: estimatedMissing, + unsupportedBlockCount: estimatedMissing, + chartBlockCount, + ready: !hasErrors && estimatedMissing === 0 && geometryMissingCount === 0, + geometryMissingCount, + visibleBlockCount: visibleCount, + coverage, + groups, }; } + +/** + * Compare two snapshots for content parity. + * Used to validate that web and export have the same content. + */ +export function compareSnapshots( + webSnapshot: ImmutableSlideSnapshot, + exportSnapshot: ImmutableSlideSnapshot +): { + match: boolean; + differences: string[]; +} { + const differences: string[] = []; + + // Compare slide IDs + if (webSnapshot.slideId !== exportSnapshot.slideId) { + differences.push(`Slide ID mismatch: ${webSnapshot.slideId} vs ${exportSnapshot.slideId}`); + } + + // Compare block count + if (webSnapshot.blocks.length !== exportSnapshot.blocks.length) { + differences.push( + `Block count mismatch: ${webSnapshot.blocks.length} vs ${exportSnapshot.blocks.length}` + ); + } + + // Compare block IDs + const webBlockIds = webSnapshot.blocks.map((b) => b.id).sort(); + const exportBlockIds = exportSnapshot.blocks.map((b) => b.id).sort(); + if (JSON.stringify(webBlockIds) !== JSON.stringify(exportBlockIds)) { + differences.push(`Block IDs mismatch: ${webBlockIds.join(",")} vs ${exportBlockIds.join(",")}`); + } + + // Compare block types + for (const webBlock of webSnapshot.blocks) { + const exportBlock = exportSnapshot.blocks.find((b) => b.id === webBlock.id); + if (!exportBlock) { + differences.push(`Block ${webBlock.id} missing in export snapshot`); + continue; + } + + if (webBlock.type !== exportBlock.type) { + differences.push( + `Block ${webBlock.id} type mismatch: ${webBlock.type} vs ${exportBlock.type}` + ); + } + } + + // Compare semantic content + const webHash = hashSlideSemanticContent(webSnapshot); + const exportHash = hashSlideSemanticContent(exportSnapshot); + if (webHash !== exportHash) { + differences.push(`Semantic content mismatch`); + } + + return { + match: differences.length === 0, + differences, + }; +} \ No newline at end of file diff --git a/skills/deckforge/starter-components/export/export-types.ts b/skills/deckforge/starter-components/export/export-types.ts index 96f1f89..8c1ff09 100644 --- a/skills/deckforge/starter-components/export/export-types.ts +++ b/skills/deckforge/starter-components/export/export-types.ts @@ -1,5 +1,6 @@ -import type { Block, DeckProject } from "../deck-types"; -import type { AssetEmbedResult } from "./pptx/pptx-assets"; +import type { Block, DeckProject, SaveState } from "../deck/types"; +import type { PreparedAsset } from "./prepare-export"; +import type { Command, DispatchResult } from "../deck/commands"; export type ExportIssueSeverity = "info" | "warning" | "error"; @@ -18,7 +19,17 @@ export type ExportIssueCode = | "hidden-slide-skipped" | "missing-speaker-notes" | "external-asset" - | "no-fallback-produced"; + | "no-fallback-produced" + | "empty-table" + | "template-chart-skipped" + | "chart-no-data" + | "invalid-geometry" + | "aspect-mismatch" + | "duplicate-element-id" + | "unresolved-image" + | "template-chart-leak" + | "chart-data-mismatch" + | "chart-count-mismatch"; export interface ExportIssue { code: ExportIssueCode; @@ -97,6 +108,24 @@ export type PptxExportability = | "poster-with-link" | "unsupported"; +export type PreflightIssueGroup = "geometry" | "assets" | "content" | "structural"; + +export interface PreflightGroupSummary { + group: PreflightIssueGroup; + label: string; + count: number; + issues: ExportIssue[]; +} + +/** Coverage invariants: expected == native + fallback and missing == 0. */ +export interface ExportCoverage { + expected: number; + native: number; + fallback: number; + missing: number; + satisfied: boolean; +} + export interface ExportPreflightResult { issues: ExportIssue[]; score: number; @@ -107,6 +136,16 @@ export interface ExportPreflightResult { missingBlockCount: number; unsupportedBlockCount: number; chartBlockCount: number; + /** True when export may proceed cleanly (no errors, zero missing geometry). */ + ready: boolean; + /** Visible blocks with no resolvable canonical frame (fail-close gate). */ + geometryMissingCount: number; + /** Number of visible blocks (the "expected" denominator). */ + visibleBlockCount: number; + /** Coverage invariants over the resolved scene. */ + coverage: ExportCoverage; + /** Diagnostics grouped by pipeline stage for the UI. */ + groups: PreflightGroupSummary[]; } export interface PptxExportConfig { @@ -125,13 +164,23 @@ export interface FontWarning { substituteFont?: string; } +/** A single styled run inside a native text element (pptxgenjs TextProps). */ +export interface PptxTextRun { + text: string; + options?: Record<string, unknown>; +} + interface PptxTextElement { type: "text"; x: number; y: number; w: number; h: number; - data: { text: string; options?: Record<string, unknown> }; + /** Stable identity of the source block (SlideDocument) for validation/diagnostics. */ + elementId?: string; + /** Stable identity of the owning slide. */ + slideId?: string; + data: { text: string | PptxTextRun[]; options?: Record<string, unknown> }; } interface PptxImageElement { @@ -140,7 +189,19 @@ interface PptxImageElement { y: number; w: number; h: number; - data: { dataUri: string; alt?: string; options?: Record<string, unknown> }; + /** Stable identity of the source block (SlideDocument) for validation/diagnostics. */ + elementId?: string; + /** Stable identity of the owning slide. */ + slideId?: string; + data: { + dataUri: string; + alt?: string; + /** Intrinsic pixel dimensions of the source image, when known. Used to + * crop cover/contain from the real aspect ratio instead of stretching. */ + naturalWidth?: number; + naturalHeight?: number; + options?: Record<string, unknown>; + }; } interface PptxShapeElement { @@ -149,6 +210,10 @@ interface PptxShapeElement { y: number; w: number; h: number; + /** Stable identity of the source block (SlideDocument) for validation/diagnostics. */ + elementId?: string; + /** Stable identity of the owning slide. */ + slideId?: string; data: { shape: string; options?: Record<string, unknown> }; } @@ -158,6 +223,10 @@ interface PptxTableElement { y: number; w: number; h: number; + /** Stable identity of the source block (SlideDocument) for validation/diagnostics. */ + elementId?: string; + /** Stable identity of the owning slide. */ + slideId?: string; data: { rows: unknown[][]; options?: Record<string, unknown> }; } @@ -167,6 +236,10 @@ interface PptxChartElement { y: number; w: number; h: number; + /** Stable identity of the source block (SlideDocument) for validation/diagnostics. */ + elementId?: string; + /** Stable identity of the owning slide. */ + slideId?: string; data: { chartType: string; data: unknown[]; options?: Record<string, unknown> }; } @@ -176,6 +249,10 @@ interface PptxFallbackElement { y: number; w: number; h: number; + /** Stable identity of the source block (SlideDocument) for validation/diagnostics. */ + elementId?: string; + /** Stable identity of the owning slide. */ + slideId?: string; data: { text: string; options?: Record<string, unknown> }; } @@ -185,6 +262,10 @@ interface PptxSvgElement { y: number; w: number; h: number; + /** Stable identity of the source block (SlideDocument) for validation/diagnostics. */ + elementId?: string; + /** Stable identity of the owning slide. */ + slideId?: string; data: { svg: string; alt?: string; options?: Record<string, unknown> }; } @@ -210,13 +291,34 @@ export interface PptxExportContext { deck: DeckProject; config: PptxExportConfig; fontWarnings: FontWarning[]; - assetCache: Map<string, AssetEmbedResult>; + /** + * The canonical, pre-resolved asset registry produced by the single + * `prepareExport` phase. Exporters MUST consume resolved bytes from here and + * must never fetch or re-resolve an asset on their own. Keyed by canonical + * asset id (manifest id or `inline:<blockId>`). + */ + assetRegistry: ReadonlyMap<string, PreparedAsset>; + /** Document pixel width of the slide (from SlideDocument.canvas). */ slideWidth: number; + /** Document pixel height of the slide (from SlideDocument.canvas). */ slideHeight: number; + /** + * PowerPoint slide size in inches, DERIVED from the document aspect ratio + * (Phase 4). webAspect === pptxAspect always. Never a hard-coded 13.333x7.5. + */ + pptxWidth: number; + pptxHeight: number; } export interface PptxBlockExport { element?: PptxSlideElement; + /** + * Additional elements produced by one source block (e.g. a process diagram + * rendered as several editable shapes + connectors). When present, all of + * them are written to the slide; `element` remains the primary representative + * used for representation planning. + */ + elements?: PptxSlideElement[]; status: BlockExportStatus; issues: ExportIssue[]; } @@ -233,6 +335,8 @@ export interface ExportDialogProps { onClose: () => void; onExport?: (result: Blob) => void; onError?: (error: Error) => void; + commit?: (command: Command) => DispatchResult | undefined; + saveNow?: (deck: DeckProject) => SaveState; } export const DEFAULT_PPTX_CONFIG: PptxExportConfig = { diff --git a/skills/deckforge/starter-components/export/fidelity/content-parity.ts b/skills/deckforge/starter-components/export/fidelity/content-parity.ts index 2d9e4f6..4ee86ac 100644 --- a/skills/deckforge/starter-components/export/fidelity/content-parity.ts +++ b/skills/deckforge/starter-components/export/fidelity/content-parity.ts @@ -1,4 +1,4 @@ -import type { Block, DeckProject } from "../../deck-types"; +import type { Block, DeckProject } from "../../deck/types"; import type { FidelityBlockReport, PptxFidelityPolicy } from "./fidelity-types"; import { FIDELITY_POLICY } from "./fidelity-policy"; @@ -11,28 +11,41 @@ function asRecord(value: unknown): ContentRecord { } export function rawText(block: Block): string { - if (typeof block.content === "string") return block.content; - return String(asRecord(block.content).text ?? ""); + const content = block.content; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + // bullets: array of lines / {text} + return content + .map((item) => + typeof item === "string" ? item : typeof (item as { text?: unknown })?.text === "string" + ? ((item as { text: string }).text) + : "", + ) + .join(" "); + } + if (!content || typeof content !== "object") return ""; + const record = asRecord(content); + if (typeof record.text === "string") return record.text; + // metric: { value, label, delta } + if (record.value != null || record.label != null || record.delta != null) { + return [record.value, record.label, record.delta].filter((v) => typeof v === "string").join(" "); + } + // process: { steps: [{ title, detail }] } + if (Array.isArray(record.steps)) { + return record.steps + .map((step) => { + const s = asRecord(step); + return [s.title, s.detail].filter((v) => typeof v === "string").join(" "); + }) + .join(" "); + } + return ""; } function meaningfulText(block: Block): number { return (rawText(block).match(VISIBLE_TEXT) ?? []).length; } -/** - * Compute text-recall content parity: the ratio of meaningful text tokens - * present in the export to the total expected across all visible blocks. - * - * This is a TEXT-based metric — it measures how much human-readable text - * survives into the PPTX output. For visual blocks (charts, diagrams, - * images) where the exported representation is SVG or raster, the metric - * falls back to the block's alt text or title. A score of 1.0 means all - * expected text is present; 0.0 means no text was exported. - * - * The metric intentionally does NOT measure visual fidelity (pixel-level - * accuracy) or structural fidelity (layout positions). Those are assessed - * separately by the OOXML structural verifier. - */ export function calculateContentParity( deck: DeckProject, blocks: FidelityBlockReport[], diff --git a/skills/deckforge/starter-components/export/pptx/block-exporters/chart.ts b/skills/deckforge/starter-components/export/pptx/block-exporters/chart.ts index 506fd3d..96b6517 100644 --- a/skills/deckforge/starter-components/export/pptx/block-exporters/chart.ts +++ b/skills/deckforge/starter-components/export/pptx/block-exporters/chart.ts @@ -1,77 +1,197 @@ +// export/pptx/block-exporters/chart.ts +// +// PPTX chart exporter that consumes the canonical ResolvedChartSpec from the +// snapshot resolver. Charts are exported as vector SVG images rendered from the +// SAME layout engine the web presenter uses (`renderChartToSvg`), placed at the +// full block frame — the web chart is an <svg viewBox="0 0 560 300"> filling the +// frame, so an embedded copy is pixel-identical. +// +// Native PowerPoint charts are deliberately NOT used: pptxgenjs/PowerPoint +// cannot reproduce the web chart's per-bar highlight color, exact plot-area +// geometry (no plot-margin API), the solid baseline under dashed gridlines, or +// the top-down category order of horizontal bars. The SVG is the only path that +// is 100% faithful to the web. + import type { PptxBlockExport, PptxBlockExporter, PptxExportContext, } from "../../export-types"; +import type { Block, ChartContent } from "../../../deck/types"; +import { chartSpecFromContent } from "../../../deck/chart-spec"; +import { exportFrameOf, frameErrorIssue } from "../export-utils"; +import type { ResolvedChartSpec } from "../../snapshot"; +import { resolveChartSpecForBlock } from "../../snapshot"; +import { renderChartToSvg } from "../../fidelity/svg/svg-chart"; -interface ChartDataPoint { - label: string; - value: number; -} +/** + * The web chart SVG has a fixed 560x300 viewBox letterboxed inside its block + * frame (preserveAspectRatio meet). The exported element is the largest 560:300 + * box that fits in the frame, centered — the visible drawing region. + */ +const CHART_ASPECT = 560 / 300; -interface ChartBlock { - id: string; - type: "chart"; - chartType?: string; - data?: ChartDataPoint[]; - content?: { type?: string; title?: string; values?: ChartDataPoint[] }; - title?: string; - x?: number; - y?: number; - w?: number; - h?: number; - frame?: { x?: number; y?: number; w?: number; h?: number }; +function chartContainedFrame(frame: { x: number; y: number; w: number; h: number }): { + x: number; + y: number; + w: number; + h: number; +} { + const frameAspect = frame.w / frame.h; + let w = frame.w; + let h = frame.h; + if (frameAspect > CHART_ASPECT) { + w = frame.h * CHART_ASPECT; + } else { + h = frame.w / CHART_ASPECT; + } + return { x: frame.x + (frame.w - w) / 2, y: frame.y + (frame.h - h) / 2, w, h }; } -const CHART_TYPE_MAP: Record<string, string> = { - bar: "bar", - "bar-horizontal": "bar", - line: "line", - pie: "pie", - doughnut: "pie", - scatter: "scatter", -}; +/** A chart data point that is definitely well-formed enough to export. */ +function hasRealChartData(content: ChartContent | undefined): boolean { + return Array.isArray(content?.values) && content.values.length > 0; +} export const chartBlockExporter: PptxBlockExporter = { type: "chart", - exportability: "native-editable", + exportability: "hybrid-rasterized", async export(block: unknown, ctx: PptxExportContext): Promise<PptxBlockExport> { - const chartBlock = block as ChartBlock; - const content = chartBlock.content; - const values: ChartDataPoint[] = content?.values ?? chartBlock.data ?? []; - const chartType = content?.type ?? chartBlock.chartType ?? "bar"; - const title = content?.title ?? chartBlock.title ?? ""; - const pptxChartType = CHART_TYPE_MAP[chartType] ?? "bar"; + const chartBlock = block as Block; + const frame = exportFrameOf(chartBlock); - const chartData = [ - { - name: title || "Data", - labels: values.map((point) => point.label), - values: values.map((point) => point.value), - }, - ]; + if (!frame) { + return { + status: "unsupported", + issues: [frameErrorIssue(chartBlock.id, "chart blocks require a resolved frame")], + }; + } + + const content = chartBlock.content as ChartContent | undefined; + + // A "New chart" template block has no real content yet: never export it as + // a genuine chart with placeholder values (A=40, B=60). + if (content?.isTemplate) { + return { + status: "skipped", + issues: [ + { + code: "template-chart-skipped", + severity: "warning", + message: `Chart block "${chartBlock.id}" is an unconfigured "New chart" template and was not exported`, + suggestedFix: "Edit the chart to add real data before exporting", + automaticFixAvailable: false, + }, + ], + }; + } + + // Malformed data (a non-array "values") is a hard error: the block is a + // source chart but cannot produce a semantic chart. This must never fall + // through to a default chart with placeholder data. + if (content && !Array.isArray(content.values)) { + return { + status: "unsupported", + issues: [ + { + code: "chart-no-data", + severity: "error", + message: `Chart block "${chartBlock.id}" has malformed data (expected an array of {label, value}) and was not exported`, + suggestedFix: "Give the chart a valid values array", + automaticFixAvailable: false, + }, + ], + }; + } + + if (!hasRealChartData(content)) { + return { + status: "skipped", + issues: [ + { + code: "chart-no-data", + severity: "warning", + message: `Chart block "${chartBlock.id}" has no data values and was skipped`, + suggestedFix: "Add data values to the chart", + automaticFixAvailable: false, + }, + ], + }; + } + + // ── Canonical spec: THE single source of truth for data + colors. ────── + const chartSpec: ResolvedChartSpec | undefined = resolveChartSpecForBlock(ctx.deck, chartBlock); + if (!chartSpec) { + return { + status: "unsupported", + issues: [ + { + code: "chart-no-data", + severity: "error", + message: `Chart block "${chartBlock.id}" could not be resolved into a semantic chart`, + suggestedFix: "Verify the chart has a valid type, values, and labels", + automaticFixAvailable: false, + }, + ], + }; + } + + // Data parity invariant: the exported spec MUST be the exact content data. + const sourceValues = content!.values; + if (chartSpec.categories.length !== sourceValues.length) { + return { + status: "unsupported", + issues: [ + { + code: "chart-data-mismatch", + severity: "error", + message: `Chart block "${chartBlock.id}" has data mismatch: ${chartSpec.categories.length} categories in spec vs ${sourceValues.length} in source`, + suggestedFix: "Verify chart data integrity", + automaticFixAvailable: false, + }, + ], + }; + } + for (let i = 0; i < sourceValues.length; i++) { + if (chartSpec.series[0]?.values[i] !== sourceValues[i].value) { + return { + status: "unsupported", + issues: [ + { + code: "chart-data-mismatch", + severity: "error", + message: `Chart block "${chartBlock.id}" value mismatch at index ${i}: expected ${sourceValues[i].value} but got ${chartSpec.series[0]?.values[i]}`, + suggestedFix: "Verify chart data integrity", + automaticFixAvailable: false, + }, + ], + }; + } + } + +// ── Render the EXACT web chart (same SVG the presenter draws). ───────── + // The browser chart is an <svg viewBox="0 0 560 300"> filling the block + // frame; preserveAspectRatio meet letterboxes the drawing into the largest + // 560:300 box, which is the visible region. Embedding that same SVG at the + // contained box reproduces the web drawing byte-for-byte: dashed gridlines, + // per-bar highlight color, exact bar geometry, category order and data + // labels like "2.4MB". + const chartFrame = chartContainedFrame(frame); + const svgString = renderChartToSvg(chartSpec); return { - status: "native", + status: "rasterized", issues: [], element: { - type: "chart", - x: chartBlock.x ?? chartBlock.frame?.x ?? 0, - y: chartBlock.y ?? chartBlock.frame?.y ?? 0, - w: chartBlock.w ?? chartBlock.frame?.w ?? ctx.slideWidth * 0.7, - h: chartBlock.h ?? chartBlock.frame?.h ?? ctx.slideHeight * 0.5, + type: "svg", + elementId: chartBlock.id, + ...chartFrame, data: { - chartType: pptxChartType, - data: chartData, - options: { - showTitle: !!title, - title, - showValue: true, - dataLabelPosition: "outEnd", - }, + svg: svgString, + alt: chartSpec.summary || chartSpec.title || "Chart", }, }, }; }, -}; \ No newline at end of file +}; diff --git a/skills/deckforge/starter-components/export/pptx/block-exporters/diagram.ts b/skills/deckforge/starter-components/export/pptx/block-exporters/diagram.ts index 6bed345..28244eb 100644 --- a/skills/deckforge/starter-components/export/pptx/block-exporters/diagram.ts +++ b/skills/deckforge/starter-components/export/pptx/block-exporters/diagram.ts @@ -1,22 +1,18 @@ +// export/pptx/block-exporters/diagram.ts + import type { PptxBlockExport, PptxBlockExporter, PptxExportContext, } from "../../export-types"; import { renderDiagramSvg } from "../../fidelity/svg/svg-diagram"; -import { mapThemeColors } from "../pptx-theme"; +import { resolveTheme, hexToPptx } from "../../resolved-theme"; +import { exportFrameOf, frameErrorIssue } from "../export-utils"; +import type { Block } from "../../../deck/types"; -interface DiagramBlock { - id: string; - type: "diagram"; +interface DiagramContent { nodes?: Array<{ id?: string; label: string } | string>; edges?: Array<{ from: string; to: string } | string>; - content?: { nodes?: Array<{ id?: string; label: string } | string>; edges?: Array<{ from: string; to: string } | string> }; - x?: number; - y?: number; - w?: number; - h?: number; - frame?: { x?: number; y?: number; w?: number; h?: number }; } export const diagramBlockExporter: PptxBlockExporter = { @@ -24,27 +20,31 @@ export const diagramBlockExporter: PptxBlockExporter = { exportability: "image-only", async export(block: unknown, ctx: PptxExportContext): Promise<PptxBlockExport> { - const diagramBlock = block as DiagramBlock; - const content = diagramBlock.content; - const nodes = content?.nodes ?? diagramBlock.nodes ?? []; - const edges = content?.edges ?? diagramBlock.edges ?? []; - const x = diagramBlock.x ?? diagramBlock.frame?.x ?? 0; - const y = diagramBlock.y ?? diagramBlock.frame?.y ?? 0; - const w = diagramBlock.w ?? diagramBlock.frame?.w ?? ctx.slideWidth * 0.6; - const h = diagramBlock.h ?? diagramBlock.frame?.h ?? ctx.slideHeight * 0.4; + const diagramBlock = block as Block; + const frame = exportFrameOf(diagramBlock); + if (!frame) { + return { + status: "unsupported", + issues: [frameErrorIssue(diagramBlock.id, "diagram blocks require a resolved frame")], + }; + } + + const content = diagramBlock.content as DiagramContent | undefined; + const nodes = content?.nodes ?? []; + const edges = content?.edges ?? []; - const theme = mapThemeColors(ctx.deck.theme); + const theme = resolveTheme(ctx.deck); const svg = renderDiagramSvg( { nodes, edges }, { - width: Math.max(1, Math.round(w)), - height: Math.max(1, Math.round(h)), + width: Math.max(1, Math.round(frame.w)), + height: Math.max(1, Math.round(frame.h)), colors: { - background: theme.background, - nodeFill: theme.light1, - nodeStroke: theme.accent1, - labelColor: theme.text, - edgeColor: theme.dark2, + background: hexToPptx(theme.tokens.background), + nodeFill: hexToPptx(theme.tokens.surface), + nodeStroke: hexToPptx(theme.tokens.primary), + labelColor: hexToPptx(theme.tokens.foreground), + edgeColor: hexToPptx(theme.tokens.muted), }, } ); @@ -54,12 +54,10 @@ export const diagramBlockExporter: PptxBlockExporter = { issues: [], element: { type: "svg", - x, - y, - w, - h, + elementId: diagramBlock.id, + ...frame, data: { svg, alt: (diagramBlock as { alt?: string }).alt }, }, }; }, -}; +}; \ No newline at end of file diff --git a/skills/deckforge/starter-components/export/pptx/block-exporters/fallback.ts b/skills/deckforge/starter-components/export/pptx/block-exporters/fallback.ts index fdce238..c517844 100644 --- a/skills/deckforge/starter-components/export/pptx/block-exporters/fallback.ts +++ b/skills/deckforge/starter-components/export/pptx/block-exporters/fallback.ts @@ -1,30 +1,39 @@ +// export/pptx/block-exporters/fallback.ts + import type { PptxBlockExport, PptxBlockExporter, PptxExportContext, } from "../../export-types"; import { renderSnapshotSvg } from "../../fidelity/svg/svg-snapshot"; +import { exportFrameOf, frameErrorIssue } from "../export-utils"; +import type { Block } from "../../../deck/types"; export const fallbackBlockExporter: PptxBlockExporter = { type: "fallback", exportability: "image-only", async export(block: unknown, ctx: PptxExportContext): Promise<PptxBlockExport> { - const anyBlock = block as Record<string, unknown>; - const blockType = (anyBlock.type as string) ?? "unknown"; - const frame = (anyBlock.frame as { x?: number; y?: number; w?: number; h?: number } | undefined) ?? {}; - const x = (anyBlock.x as number) ?? frame.x ?? 0; - const y = (anyBlock.y as number) ?? frame.y ?? 0; - const w = (anyBlock.w as number) ?? frame.w ?? ctx.slideWidth * 0.5; - const h = (anyBlock.h as number) ?? frame.h ?? ctx.slideHeight * 0.3; + const anyBlock = block as Block; + const frame = exportFrameOf(anyBlock); + if (!frame) { + return { + status: "unsupported", + issues: [frameErrorIssue(anyBlock.id, "fallback blocks require a resolved frame")], + }; + } + const blockType = anyBlock.type ?? "unknown"; const content = anyBlock.content; const text = typeof content === "string" ? content : content ? JSON.stringify(content) : ""; - const alt = (anyBlock.alt as string) ?? (anyBlock.ariaLabel as string) ?? ""; + const alt = anyBlock.alt ?? anyBlock.ariaLabel ?? ""; + + const finalW = Math.max(100, frame.w); + const finalH = Math.max(60, frame.h); const svg = renderSnapshotSvg({ - width: Math.max(1, Math.round(w)), - height: Math.max(1, Math.round(h)), + width: Math.round(finalW), + height: Math.round(finalH), title: blockType, text, alt, @@ -43,12 +52,10 @@ export const fallbackBlockExporter: PptxBlockExporter = { ], element: { type: "svg", - x, - y, - w, - h, + elementId: anyBlock.id, + ...frame, data: { svg, alt }, }, }; }, -}; +}; \ No newline at end of file diff --git a/skills/deckforge/starter-components/export/pptx/block-exporters/image.ts b/skills/deckforge/starter-components/export/pptx/block-exporters/image.ts index bd494b4..9fc925d 100644 --- a/skills/deckforge/starter-components/export/pptx/block-exporters/image.ts +++ b/skills/deckforge/starter-components/export/pptx/block-exporters/image.ts @@ -1,55 +1,60 @@ +// export/pptx/block-exporters/image.ts +// +// The image exporter consumes the canonical, pre-resolved asset registry built +// by the single `prepareExport` phase. It performs NO network work of its own: +// if the preparation phase failed to resolve a required image, this exporter +// reports a blocking error (Fidelity First) or a truthful rasterized fallback +// (Editability First) — never a silent omission, and never a re-fetch that +// could contradict what preflight reported. + import type { PptxBlockExport, PptxBlockExporter, PptxExportContext, PptxSlideElement, } from "../../export-types"; -import type { DeckProject } from "../../../deck-types"; -import { embedAsset } from "../pptx-assets"; - -interface ImageContentLike { - assetId?: string; - src?: string; - alt?: string; - fit?: string; -} - -interface ImageBlock { - id: string; - type: "image"; - src?: string; - alt?: string; - content?: ImageContentLike; - x?: number; - y?: number; - w?: number; - h?: number; - frame?: { x?: number; y?: number; w?: number; h?: number }; -} +import { canonicalAssetRef } from "../../../deck/assets"; +import { exportFrameOf, frameErrorIssue } from "../export-utils"; +import { documentUnitToPptxInches } from "../../geometry"; +import { PLACEHOLDER_IMAGE_DATA_URI } from "../pptx-placeholder"; +import { readImageSizeFromDataUri } from "../../image-dimensions"; +import type { Block, ImageBlockContent } from "../../../deck/types"; -function imageGeometry(block: ImageBlock, ctx: PptxExportContext) { +/** + * PPTX sizing box (inches) for an image element, derived from the resolved + * document-pixel frame. pptxgenjs interprets `sizing.w/h` as INCHES when the + * value is < 100 and as EMU otherwise — feeding it document pixels (e.g. 852) + * produced a 0.001"-wide picture. Inches are always the correct unit here. + */ +function sizingBoxInches( + frame: { w: number; h: number }, + ctx: PptxExportContext, +): { w: number; h: number } { return { - x: block.x ?? block.frame?.x ?? 0, - y: block.y ?? block.frame?.y ?? 0, - w: block.w ?? block.frame?.w ?? ctx.slideWidth * 0.5, - h: block.h ?? block.frame?.h ?? ctx.slideHeight * 0.5, + w: documentUnitToPptxInches(frame.w, ctx.slideWidth, ctx.pptxWidth), + h: documentUnitToPptxInches(frame.h, ctx.slideHeight, ctx.pptxHeight), }; } -function placeholderElement(block: ImageBlock, ctx: PptxExportContext): PptxSlideElement { - return { - type: "fallback", - ...imageGeometry(block, ctx), - data: { - text: `[image unavailable: ${block.id}]`, - options: { - fill: { color: "FFF3CD" }, - line: { color: "FFC107", width: 1 }, - fontSize: 12, - color: "856404", - }, - }, - }; +/** + * Intrinsic dimensions for the raster that will be embedded. The bytes are + * authoritative (they match the actual embedded image), so they take priority + * over the manifest record, which can be stale or absent (URL-pasted sources, + * decks saved before upload dimensions were tracked). + */ +function naturalSize( + dataUri: string, + recordedWidth?: number, + recordedHeight?: number, +): { width: number; height: number } | undefined { + const decoded = readImageSizeFromDataUri(dataUri); + if (decoded) { + return decoded; + } + if (recordedWidth && recordedHeight) { + return { width: recordedWidth, height: recordedHeight }; + } + return undefined; } export const imageBlockExporter: PptxBlockExporter = { @@ -57,23 +62,54 @@ export const imageBlockExporter: PptxBlockExporter = { exportability: "native-editable", async export(block: unknown, ctx: PptxExportContext): Promise<PptxBlockExport> { - const imageBlock = block as ImageBlock; - const content = imageBlock.content; - const deckWithAssets = ctx.deck as DeckProject & { assets?: Array<{ id: string; src?: string; alt?: string }> }; - const asset = content?.assetId - ? deckWithAssets.assets?.find((entry) => entry.id === content.assetId) - : undefined; - const src = content?.src ?? imageBlock.src ?? asset?.src ?? ""; - const alt = content?.alt ?? imageBlock.alt ?? asset?.alt ?? ""; - - if (!src) { + const imageBlock = block as Block; + const frame = exportFrameOf(imageBlock); + + // A frame IS required: never place at (0,0) by defaulting w/h. + if (!frame) { + return { + status: "unsupported", + issues: [frameErrorIssue(imageBlock.id, "image blocks require a resolved frame")], + }; + } + + const content = (imageBlock.content as ImageBlockContent | undefined) ?? {}; + const ref = canonicalAssetRef(ctx.deck, imageBlock); + const alt = content.alt ?? imageBlock.alt ?? ""; + const fit = content.fit ?? "cover"; + + const fix = + "Use a local asset or a data: URL so the image can be embedded offline"; + + // Placeholder image block: no source and no asset id. The web renders a + // designed placeholder, so PPTX keeps the visual slot filled with the + // bundled placeholder raster — a truthful fallback, not an omission. + if (!ref) { + return { + status: "rasterized", + issues: [ + { + code: "no-fallback-produced", + severity: "info", + message: `Image block "${imageBlock.id}" has no image source; the bundled placeholder raster was embedded`, + suggestedFix: "Attach a local asset to the image block or use a data: URL", + automaticFixAvailable: true, + }, + ], + element: placeholderElement(imageBlock.id, frame, alt, fit, ctx), + }; + } + + // Orphan: the block references a manifest asset that does not exist. This + // is a real resolution failure, surfaced the same way as a dead URL. + if (ref.orphan) { return { - status: "skipped", + status: "unsupported", issues: [ { code: "image-load-failed", - severity: "warning", - message: `Image block "${imageBlock.id}" has no resolvable source and was skipped`, + severity: "error", + message: `Image block "${imageBlock.id}" references asset "${ref.assetId}" which has no manifest entry; the image cannot be embedded`, suggestedFix: "Attach a local asset to the image block or use a data: URL", automaticFixAvailable: false, }, @@ -81,39 +117,112 @@ export const imageBlockExporter: PptxBlockExporter = { }; } - const assetResult = await embedAsset(src, ctx.assetCache); + const entry = ctx.assetRegistry.get(ref.assetId); + const source = entry?.originalSrc ?? ref.src ?? ""; - if (!assetResult.dataUri) { + if (!source) { return { - status: "substituted", + status: "unsupported", issues: [ { code: "image-load-failed", - severity: "warning", - message: `Image "${src}" could not be loaded; replaced with a placeholder box`, - suggestedFix: "Use a local asset or a data: URL so the image can be embedded offline", + severity: "error", + message: `Image block "${imageBlock.id}" has no resolvable source and cannot be embedded`, + suggestedFix: fix, automaticFixAvailable: false, }, ], - element: placeholderElement(imageBlock, ctx), }; } - const geometry = imageGeometry(imageBlock, ctx); - return { - status: "native", - issues: [], - element: { + if (entry && entry.status === "ready" && entry.resolvedDataUri) { + const natural = naturalSize(entry.resolvedDataUri, entry.width, entry.height); + const element: PptxSlideElement = { type: "image", - ...geometry, + elementId: imageBlock.id, + ...frame, data: { - dataUri: assetResult.dataUri, + dataUri: entry.resolvedDataUri, alt, + naturalWidth: natural?.width, + naturalHeight: natural?.height, options: { - sizing: { type: "contain", w: geometry.w, h: geometry.h }, + sizing: { + type: fit === "cover" ? "cover" : "contain", + ...sizingBoxInches(frame, ctx), + }, + margin: 0, }, }, - }, + }; + return { status: "native", issues: [], element }; + } + + // The preparation phase failed to resolve this required image. + const reason = + entry?.error ?? "network error, CORS restriction, or missing asset"; + const base = { + code: "image-load-failed" as const, + automaticFixAvailable: false as const, + }; + + // Fidelity First never ships a successful export with a placeholder in + // place of a real image: an unresolved required image is a blocking error. + if (ctx.config.mode === "fidelity-first") { + return { + status: "unsupported", + issues: [ + { + ...base, + severity: "error", + message: `Image "${source}" (block "${imageBlock.id}") could not be loaded: ${reason}`, + suggestedFix: fix, + }, + ], + }; + } + + // Editability-first: keep the visual slot filled with the bundled + // placeholder raster so the image still "appears" in PPTX. + return { + status: "rasterized", + issues: [ + { + ...base, + severity: "warning", + message: `Image "${source}" (block "${imageBlock.id}") could not be loaded: ${reason}; a bundled placeholder image was embedded in its place`, + suggestedFix: fix, + }, + ], + element: placeholderElement(imageBlock.id, frame, alt, fit, ctx), }; }, -}; \ No newline at end of file +}; + +function placeholderElement( + elementId: string, + frame: { x: number; y: number; w: number; h: number }, + alt: string, + fit: string, + ctx: PptxExportContext, +): PptxSlideElement { + const natural = readImageSizeFromDataUri(PLACEHOLDER_IMAGE_DATA_URI); + return { + type: "image", + elementId, + ...frame, + data: { + dataUri: PLACEHOLDER_IMAGE_DATA_URI, + alt, + naturalWidth: natural?.width, + naturalHeight: natural?.height, + options: { + sizing: { + type: fit === "cover" ? "cover" : "contain", + ...sizingBoxInches(frame, ctx), + }, + margin: 0, + }, + }, + }; +} \ No newline at end of file diff --git a/skills/deckforge/starter-components/export/pptx/block-exporters/index.ts b/skills/deckforge/starter-components/export/pptx/block-exporters/index.ts index f20ddc4..7b170ad 100644 --- a/skills/deckforge/starter-components/export/pptx/block-exporters/index.ts +++ b/skills/deckforge/starter-components/export/pptx/block-exporters/index.ts @@ -8,8 +8,8 @@ import { calloutBlockExporter, citationBlockExporter, metricBlockExporter, - processBlockExporter, } from "./text"; +import { processBlockExporter } from "./process"; import { imageBlockExporter } from "./image"; import { shapeBlockExporter } from "./shape"; import { tableBlockExporter } from "./table"; diff --git a/skills/deckforge/starter-components/export/pptx/block-exporters/shape.ts b/skills/deckforge/starter-components/export/pptx/block-exporters/shape.ts index ee2a64b..d7c4d51 100644 --- a/skills/deckforge/starter-components/export/pptx/block-exporters/shape.ts +++ b/skills/deckforge/starter-components/export/pptx/block-exporters/shape.ts @@ -1,21 +1,18 @@ +// export/pptx/block-exporters/shape.ts + import type { PptxBlockExport, PptxBlockExporter, PptxExportContext, } from "../../export-types"; +import { exportFrameOf, frameErrorIssue } from "../export-utils"; +import type { Block } from "../../../deck/types"; interface ShapeBlock { - id: string; - type: "shape"; shapeType?: string; fill?: string; stroke?: string; strokeWidth?: number; - x?: number; - y?: number; - w?: number; - h?: number; - frame?: { x?: number; y?: number; w?: number; h?: number }; } const SHAPE_MAP: Record<string, string> = { @@ -34,25 +31,32 @@ export const shapeBlockExporter: PptxBlockExporter = { exportability: "native-editable", async export(block: unknown, ctx: PptxExportContext): Promise<PptxBlockExport> { - const shapeBlock = block as ShapeBlock; - const pptxShape = SHAPE_MAP[shapeBlock.shapeType ?? "rectangle"] ?? "rect"; + const shapeBlock = block as Block; + const frame = exportFrameOf(shapeBlock); + if (!frame) { + return { + status: "unsupported", + issues: [frameErrorIssue(shapeBlock.id, "shape blocks require a resolved frame")], + }; + } + + const props = shapeBlock.content as ShapeBlock | undefined; + const pptxShape = SHAPE_MAP[props?.shapeType ?? "rectangle"] ?? "rect"; return { status: "native", issues: [], element: { type: "shape", - x: shapeBlock.x ?? shapeBlock.frame?.x ?? 0, - y: shapeBlock.y ?? shapeBlock.frame?.y ?? 0, - w: shapeBlock.w ?? shapeBlock.frame?.w ?? 2, - h: shapeBlock.h ?? shapeBlock.frame?.h ?? 2, + elementId: shapeBlock.id, + ...frame, data: { shape: pptxShape, options: { - fill: { color: shapeBlock.fill?.replace("#", "") ?? "FFFFFF" }, + fill: { color: props?.fill?.replace("#", "") ?? "FFFFFF" }, line: { - color: shapeBlock.stroke?.replace("#", "") ?? "000000", - width: shapeBlock.strokeWidth ?? 1, + color: props?.stroke?.replace("#", "") ?? "000000", + width: props?.strokeWidth ?? 1, }, }, }, diff --git a/skills/deckforge/starter-components/export/pptx/block-exporters/table.ts b/skills/deckforge/starter-components/export/pptx/block-exporters/table.ts index 550af9f..f241a4f 100644 --- a/skills/deckforge/starter-components/export/pptx/block-exporters/table.ts +++ b/skills/deckforge/starter-components/export/pptx/block-exporters/table.ts @@ -1,8 +1,12 @@ +// export/pptx/block-exporters/table.ts + import type { PptxBlockExport, PptxBlockExporter, PptxExportContext, } from "../../export-types"; +import { exportFrameOf, frameErrorIssue } from "../export-utils"; +import type { Block } from "../../../deck/types"; interface TableCell { text: string; @@ -11,16 +15,9 @@ interface TableCell { fill?: string; } -interface TableBlock { - id: string; - type: "table"; +interface TableBlockContent { rows: TableCell[][]; headerRow?: boolean; - x?: number; - y?: number; - w?: number; - h?: number; - frame?: { x?: number; y?: number; w?: number; h?: number }; } export const tableBlockExporter: PptxBlockExporter = { @@ -28,13 +25,24 @@ export const tableBlockExporter: PptxBlockExporter = { exportability: "native-editable", async export(block: unknown, ctx: PptxExportContext): Promise<PptxBlockExport> { - const tableBlock = block as TableBlock; + const tableBlock = block as Block; + const frame = exportFrameOf(tableBlock); + if (!frame) { + return { + status: "unsupported", + issues: [frameErrorIssue(tableBlock.id, "table blocks require a resolved frame")], + }; + } + + const content = tableBlock.content as TableBlockContent | undefined; + const rows = content?.rows ?? []; + const headerRow = content?.headerRow ?? false; - const pptxRows = tableBlock.rows.map((row, rowIdx) => + const pptxRows = rows.map((row, rowIdx) => row.map((cell) => ({ text: cell.text, options: { - bold: cell.bold ?? (tableBlock.headerRow && rowIdx === 0), + bold: cell.bold ?? (headerRow && rowIdx === 0), color: cell.color?.replace("#", "") ?? "000000", fill: { color: cell.fill?.replace("#", "") ?? "FFFFFF" }, valign: "middle", @@ -48,10 +56,8 @@ export const tableBlockExporter: PptxBlockExporter = { issues: [], element: { type: "table", - x: tableBlock.x ?? tableBlock.frame?.x ?? 0, - y: tableBlock.y ?? tableBlock.frame?.y ?? 0, - w: tableBlock.w ?? tableBlock.frame?.w ?? ctx.slideWidth * 0.8, - h: tableBlock.h ?? tableBlock.frame?.h ?? ctx.slideHeight * 0.5, + elementId: tableBlock.id, + ...frame, data: { rows: pptxRows, options: { diff --git a/skills/deckforge/starter-components/export/pptx/block-exporters/text.ts b/skills/deckforge/starter-components/export/pptx/block-exporters/text.ts index 0c79b82..10dcb66 100644 --- a/skills/deckforge/starter-components/export/pptx/block-exporters/text.ts +++ b/skills/deckforge/starter-components/export/pptx/block-exporters/text.ts @@ -1,3 +1,8 @@ +// export/pptx/block-exporters/text.ts +// +// PPTX text exporter that uses the resolved theme for colors and fonts. +// This ensures typography parity between web and PPTX. + import type { ExportIssue, PptxBlockExport, @@ -5,72 +10,152 @@ import type { PptxExportContext, PptxSlideElement, } from "../../export-types"; -import { checkFontCompatibility } from "../pptx-fonts"; +import { + browserTypographyFor, + estimateTextHeightPx, + exportFrameOf, + fontSizeToPpt, + frameErrorIssue, + pptFontFor, + type BrowserTypography, +} from "../export-utils"; +import { resolveTheme, hexToPptx } from "../../resolved-theme"; +import type { Block } from "../../../deck/types"; const MAX_TEXT_LENGTH = 4000; -interface BlockGeometry { - x?: number; - y?: number; - w?: number; - h?: number; - frame?: { x?: number; y?: number; w?: number; h?: number }; +interface BlockLike { + id: string; + type: string; } -interface TextBlock extends BlockGeometry { - id: string; - type: "text" | "heading"; - content?: unknown; - fontFamily?: string; - fontSize?: number; - fontWeight?: string; - color?: string; - textAlign?: string; +/** Text content arrived as a plain string, array of lines, or {text|value}. */ +function stringContent(block: { content?: unknown }): string { + if (typeof block.content === "string") return block.content; + if (Array.isArray(block.content)) { + return block.content.filter((item): item is string => typeof item === "string").join("\n"); + } + if (block.content && typeof block.content === "object") { + const obj = block.content as Record<string, unknown>; + if (typeof obj.text === "string") return obj.text; + if (typeof obj.value === "string") return obj.value; + if (obj.label && typeof obj.label === "string") return obj.label; + } + return ""; } -function geometry(block: BlockGeometry, ctx: PptxExportContext, defaultW: number, defaultH: number) { - return { - x: block.x ?? block.frame?.x ?? 0, - y: block.y ?? block.frame?.y ?? 0, - w: block.w ?? block.frame?.w ?? defaultW, - h: block.h ?? block.frame?.h ?? defaultH, - }; +/** + * Map a web letter-spacing (em units) to PPTX charSpacing (points), using the + * exact same document-px -> point conversion as fontSizeToPpt so the exported + * tracking matches the browser proportionally. + */ +function charSpacingOf( + typography: BrowserTypography, + fontPx: number, + ctx: PptxExportContext, +): number | undefined { + const em = typography.letterSpacingEm ?? 0; + if (!em) return undefined; + const sign = em < 0 ? -1 : 1; + return Math.round(fontSizeToPpt(fontPx * Math.abs(em), ctx) * sign * 100) / 100; } -function textElement( +/** + * Resolve a text block into a PPTX text element with geometry and typography + * DERIVED from the document (Phase 5/7). Never default-features a missing + * frame to (0,0): a frame-less block is a geometry error, not an invisible + * top-left text box. + */ +function buildTextElement( text: string, - block: BlockGeometry, + block: unknown, ctx: PptxExportContext, - options: Record<string, unknown> = {} -): PptxSlideElement { + containerWidthPx: number, + extra: Record<string, unknown> = {}, +): PptxSlideElement | null { + const frame = exportFrameOf(block as Block); + if (!frame) return null; + + const b = block as Block; + const typography = browserTypographyFor(b, containerWidthPx > 0 ? containerWidthPx : frame.w); + const fontPx = (extra.fontSizePx as number) ?? typography.fontSizePx; + const fontSizePt = fontSizeToPpt(fontPx, ctx); + + // Use resolved theme for colors and fonts + const theme = resolveTheme(ctx.deck); + const explicitFont = (b as { fontFamily?: string }).fontFamily ?? ""; + // Headings and the metric value render in the theme heading font on the web + // (BlockRenderer styleFrom + styles.css), so they must not use the body font. + const isHeadingLike = b.type === "heading" || b.type === "metric"; + const webFont = explicitFont || (isHeadingLike ? theme.typography.headingFont : theme.typography.bodyFont); + const fontFace = pptFontFor(webFont, ctx); + + const style = b.style ?? {}; + const align = (extra.textAlign as string) ?? (style as { align?: string }).align ?? "left"; + const valign = (extra.valign as string) ?? "top"; + + // Resolve color from theme tokens (web truth = styles.css): + // - citations are explicitly muted (`.block-citation`). + // - the meta variant is foreground at 75% opacity; muted approximates it. + // - callouts and kickers inherit the base foreground color. + let color = theme.tokens.foreground; + if (b.type === "citation") { + color = theme.tokens.muted; + } else if (style.variant === "meta") { + color = theme.tokens.muted; + } + + const charSpacing = charSpacingOf(typography, fontPx, ctx); + const options: Record<string, unknown> = { + fontFace, + fontSize: fontSizePt, + bold: (extra.bold as boolean) ?? typography.bold, + italic: (extra.italic as boolean) ?? typography.italic, + color: hexToPptx(color), + align, + valign, + wrap: true, + breakLine: true, + autoFit: false, + margin: 0, + lineSpacingMultiple: typography.lineHeight, + breakMustFit: true, + }; + if (charSpacing !== undefined) options.charSpacing = charSpacing; + + // Kicker blocks render `text-transform: uppercase` on the web (styleFrom); + // the exported text must carry the same casing. + const outText = style.variant === "kicker" ? text.toUpperCase() : text; + return { type: "text", - ...geometry(block, ctx, ctx.slideWidth * 0.8, 1), - data: { text, options }, + elementId: b.id, + x: frame.x, + y: frame.y, + w: frame.w, + h: frame.h, + data: { + text: outText, + options, + }, }; } -function stringContent(block: { content?: unknown }): string { - return typeof block.content === "string" ? block.content : ""; +function frameIssueIfMissing(block: BlockLike): ExportIssue | null { + const frame = exportFrameOf(block as Block); + if (frame) return null; + return frameErrorIssue(block.id, "text blocks require a resolved frame"); } async function exportTextBlock(block: unknown, ctx: PptxExportContext): Promise<PptxBlockExport> { - const textBlock = block as TextBlock; + const textBlock = block as BlockLike & { content?: unknown }; const text = stringContent(textBlock); - const fontFamily = textBlock.fontFamily ?? "Arial"; const issues: ExportIssue[] = []; + const frame = exportFrameOf(block as Block); + const containerWidth = frame?.w ?? ctx.slideWidth; - const fontWarning = checkFontCompatibility(fontFamily); - if (fontWarning) { - ctx.fontWarnings.push(fontWarning); - issues.push({ - code: "missing-font", - severity: "warning", - message: `Font "${fontFamily}" is not a PowerPoint-safe font and may be substituted with ${fontWarning.substituteFont}`, - suggestedFix: `Use a PPTX-safe font like ${fontWarning.substituteFont}`, - automaticFixAvailable: false, - }); - } + const missing = frameIssueIfMissing(textBlock); + if (missing) return { status: "unsupported", issues: [missing] }; if (text.length > MAX_TEXT_LENGTH) { issues.push({ @@ -81,125 +166,252 @@ async function exportTextBlock(block: unknown, ctx: PptxExportContext): Promise< }); } + const element = buildTextElement(text, block, ctx, containerWidth, { + textAlign: (block as { textAlign?: string }).textAlign, + }); + return { - status: "native", - issues, - element: textElement(text, textBlock, ctx, { - fontFace: fontFamily, - fontSize: textBlock.fontSize ?? 18, - bold: textBlock.fontWeight === "bold", - color: textBlock.color?.replace("#", "") ?? "000000", - align: textBlock.textAlign ?? "left", - valign: "top", - wrap: true, - }), + status: element ? "native" : "unsupported", + issues: element ? issues : [...issues, frameErrorIssue(textBlock.id, "could not resolve geometry")], + element: element ?? undefined, }; } async function exportBulletsBlock(block: unknown, ctx: PptxExportContext): Promise<PptxBlockExport> { - const bulletsBlock = block as BlockGeometry & { content?: unknown }; + const bulletsBlock = block as BlockLike & { content?: unknown }; const lines = Array.isArray(bulletsBlock.content) ? bulletsBlock.content.filter((line): line is string => typeof line === "string") : []; - const text = lines.map((line) => `• ${line}`).join("\n"); + const frame = exportFrameOf(bulletsBlock as Block); + const missing = frameIssueIfMissing(bulletsBlock); + if (missing) return { status: "unsupported", issues: [missing] }; + + const b = bulletsBlock as Block; + const typography = browserTypographyFor(b, frame?.w ?? ctx.slideWidth); + const fontSizePt = fontSizeToPpt(typography.fontSizePx, ctx); + const theme = resolveTheme(ctx.deck); + const fontFace = pptFontFor(theme.typography.bodyFont, ctx); + + // Web truth (styles.css `.block-bullets`): flex column with `gap: 0.4em` + // (6.4px at 16px) and `padding-left: 1.1em` (17.6px); `li::marker` uses the + // theme secondary while the text stays foreground. The 16px font-size comes + // from browserTypographyFor (the list inherits the body font). We reproduce + // the padding by insetting the element, the inter-item gap with a paragraph + // space-after on every line but the last, and the marker as a secondary run. + const bulletColor = hexToPptx(theme.tokens.secondary); + const textColor = hexToPptx(theme.tokens.foreground); + const gapPt = fontSizeToPpt(6.4, ctx); + const runs = lines.flatMap((line, index) => { + const isLast = index === lines.length - 1; + const runsForLine: Array<{ text: string; options: Record<string, unknown> }> = [ + { text: "• ", options: { fontFace, fontSize: fontSizePt, bold: false, color: bulletColor } }, + { + text: line, + options: { + fontFace, + fontSize: fontSizePt, + bold: false, + color: textColor, + ...(isLast ? {} : { paraSpaceAfter: gapPt }), + }, + }, + ]; + if (!isLast) { + runsForLine.push({ text: "", options: { fontFace, fontSize: fontSizePt, breakLine: true } }); + } + return runsForLine; + }); + const paddingLeftPx = 17.6; + const element: PptxSlideElement = { + type: "text", + elementId: bulletsBlock.id, + x: frame!.x + paddingLeftPx, + y: frame!.y, + w: Math.max(20, frame!.w - paddingLeftPx), + h: frame!.h, + data: { + text: runs, + options: { + align: "left", + valign: "top", + wrap: true, + breakLine: true, + autoFit: false, + margin: 0, + lineSpacingMultiple: typography.lineHeight, + }, + }, + }; return { status: "native", issues: [], - element: textElement(text, bulletsBlock, ctx, { - fontFace: "Arial", - fontSize: 16, - color: "333333", - align: "left", - valign: "top", - wrap: true, - }), + element, }; } async function exportCalloutBlock(block: unknown, ctx: PptxExportContext): Promise<PptxBlockExport> { - const calloutBlock = block as BlockGeometry & { content?: unknown }; + const calloutBlock = block as BlockLike & { content?: unknown }; + const frame = exportFrameOf(calloutBlock as Block); + const missing = frameIssueIfMissing(calloutBlock); + if (missing) return { status: "unsupported", issues: [missing] }; + + const b = calloutBlock as Block; + const theme = resolveTheme(ctx.deck); + const typography = browserTypographyFor(b, frame?.w ?? ctx.slideWidth); + const fontPx = typography.fontSizePx; + + // Web truth (styles.css `.block-callout`): a 3px secondary left border with + // `padding: 0.4em 0 0.4em 0.8em` (top/bottom and left, right = 0) and top + // text alignment. The paddings scale with the callout's own font-size. + const borderW = 3; + const padTop = 0.4 * fontPx; + const padLeft = 0.8 * fontPx; + + const accentBar: PptxSlideElement = { + type: "shape", + elementId: calloutBlock.id, + x: frame!.x, + y: frame!.y, + w: borderW, + h: frame!.h, + data: { + shape: "rect", + options: { + fill: { color: hexToPptx(theme.tokens.secondary) }, + line: { color: hexToPptx(theme.tokens.secondary), width: 0 }, + }, + }, + }; + + const textEl = buildTextElement(stringContent(calloutBlock), calloutBlock, ctx, frame?.w ?? ctx.slideWidth, { + valign: "top", + }); + if (!textEl) { + return { status: "unsupported", issues: [frameErrorIssue(calloutBlock.id, "no geometry")] }; + } + const insetEl: PptxSlideElement = { + ...textEl, + x: frame!.x + borderW + padLeft, + y: frame!.y + padTop, + w: Math.max(20, frame!.w - borderW - padLeft), + h: Math.max(20, frame!.h - padTop * 2), + }; return { status: "native", issues: [], - element: textElement(stringContent(calloutBlock), calloutBlock, ctx, { - fontFace: "Arial", - fontSize: 18, - bold: true, - color: "1F2937", - align: "left", - valign: "middle", - wrap: true, - }), + element: textEl, + elements: [accentBar, insetEl], }; } async function exportCitationBlock(block: unknown, ctx: PptxExportContext): Promise<PptxBlockExport> { - const citationBlock = block as BlockGeometry & { content?: unknown }; + const citationBlock = block as BlockLike & { content?: unknown }; + const frame = exportFrameOf(citationBlock as Block); + const missing = frameIssueIfMissing(citationBlock); + if (missing) return { status: "unsupported", issues: [missing] }; + const element = buildTextElement(stringContent(citationBlock), citationBlock, ctx, frame?.w ?? ctx.slideWidth, {}); return { - status: "native", - issues: [], - element: textElement(stringContent(citationBlock), citationBlock, ctx, { - fontFace: "Arial", - fontSize: 12, - italic: true, - color: "6B7280", - align: "left", - valign: "top", - wrap: true, - }), + status: element ? "native" : "unsupported", + issues: element ? [] : [frameErrorIssue(citationBlock.id, "no geometry")], + element: element ?? undefined, }; } async function exportMetricBlock(block: unknown, ctx: PptxExportContext): Promise<PptxBlockExport> { - const metricBlock = block as BlockGeometry & { + const metricBlock = block as BlockLike & { content?: { value?: unknown; label?: unknown; delta?: unknown }; }; const content = metricBlock.content; const value = typeof content?.value === "string" ? content.value : ""; const label = typeof content?.label === "string" ? content.label : ""; const delta = typeof content?.delta === "string" ? content.delta : ""; - const text = [value, label, delta].filter(Boolean).join("\n"); + const frame = exportFrameOf(metricBlock as Block); + const missing = frameIssueIfMissing(metricBlock); + if (missing) return { status: "unsupported", issues: [missing] }; - return { - status: "native", - issues: [], - element: textElement(text, metricBlock, ctx, { - fontFace: "Arial", - fontSize: 24, - bold: true, - color: "111827", - align: "left", - valign: "middle", - wrap: true, - }), - }; -} + const b = metricBlock as Block; + const theme = resolveTheme(ctx.deck); + const headingFontFace = pptFontFor(theme.typography.headingFont, ctx); + const bodyFontFace = pptFontFor(theme.typography.bodyFont, ctx); -async function exportProcessBlock(block: unknown, ctx: PptxExportContext): Promise<PptxBlockExport> { - const processBlock = block as BlockGeometry & { - content?: { steps?: Array<{ title?: unknown; detail?: unknown }> }; - }; - const steps = processBlock.content?.steps ?? []; - const text = steps - .map((step, index) => { - const title = typeof step.title === "string" ? step.title : ""; - const detail = typeof step.detail === "string" && step.detail ? ` \u2014 ${step.detail}` : ""; - return `${index + 1}. ${title}${detail}`; - }) - .join("\n"); + // Web truth (styles.css .block-metric): + // value -> heading font, clamp(64px,9cqw,128px), weight 400, primary, -0.03em + // label -> clamp(13px,1.8cqw,20px), muted, margin-top 0.4em (0.4 * label size) + // delta -> weight 700, clamp(12px,1.6cqw,17px), secondary, margin-top 0.5em + const w = frame?.w ?? ctx.slideWidth; + const valuePx = Math.min(128, Math.max(64, w * 0.09)); + const labelPx = Math.min(20, Math.max(13, w * 0.018)); + const deltaPx = Math.min(17, Math.max(12, w * 0.016)); + const valuePt = fontSizeToPpt(valuePx, ctx); + const labelPt = fontSizeToPpt(labelPx, ctx); + const deltaPt = fontSizeToPpt(deltaPx, ctx); + const valueAfterPt = Math.round(fontSizeToPpt(0.4 * labelPx, ctx) * 100) / 100; + const labelAfterPt = Math.round(fontSizeToPpt(0.5 * deltaPx, ctx) * 100) / 100; + + const runs: Array<{ text: string; options: Record<string, unknown> }> = []; + if (value) { + runs.push({ + text: value, + options: { + fontFace: headingFontFace, + fontSize: valuePt, + bold: false, + color: hexToPptx(theme.tokens.primary), + charSpacing: Math.round(fontSizeToPpt(valuePx * 0.03, ctx) * -100) / 100, // -0.03em + paraSpaceAfter: valueAfterPt, + }, + }); + } + if (label) { + runs.push({ + text: label, + options: { + fontFace: bodyFontFace, + fontSize: labelPt, + bold: false, + color: hexToPptx(theme.tokens.muted), + breakLine: true, + paraSpaceAfter: labelAfterPt, + }, + }); + } + if (delta) { + runs.push({ + text: delta, + options: { + fontFace: bodyFontFace, + fontSize: deltaPt, + bold: true, + color: hexToPptx(theme.tokens.secondary), + breakLine: true, + }, + }); + } + const element: PptxSlideElement = { + type: "text", + elementId: metricBlock.id, + x: frame!.x, + y: frame!.y, + w: frame!.w, + h: frame!.h, + data: { + text: runs, + options: { + align: "left", + valign: "top", + wrap: true, + breakLine: true, + autoFit: false, + margin: 0, + }, + }, + }; return { status: "native", issues: [], - element: textElement(text, processBlock, ctx, { - fontFace: "Arial", - fontSize: 14, - color: "333333", - align: "left", - valign: "top", - wrap: true, - }), + element, }; } @@ -238,9 +450,3 @@ export const metricBlockExporter: PptxBlockExporter = { exportability: "native-editable", export: exportMetricBlock, }; - -export const processBlockExporter: PptxBlockExporter = { - type: "process", - exportability: "image-only", - export: exportProcessBlock, -}; diff --git a/skills/deckforge/starter-components/export/pptx/block-exporters/video.ts b/skills/deckforge/starter-components/export/pptx/block-exporters/video.ts index 19e9a2e..b6a8700 100644 --- a/skills/deckforge/starter-components/export/pptx/block-exporters/video.ts +++ b/skills/deckforge/starter-components/export/pptx/block-exporters/video.ts @@ -1,3 +1,5 @@ +// export/pptx/block-exporters/video.ts + import type { PptxBlockExport, PptxBlockExporter, @@ -5,6 +7,8 @@ import type { } from "../../export-types"; import { renderSnapshotSvg } from "../../fidelity/svg/svg-snapshot"; import { mapThemeColors } from "../pptx-theme"; +import { exportFrameOf, frameErrorIssue } from "../export-utils"; +import type { Block } from "../../../deck/types"; interface VideoChapter { title?: unknown; @@ -18,26 +22,21 @@ interface VideoContent { chapter?: VideoChapter; } -interface VideoBlock { - id: string; - type: "video"; - content?: VideoContent; - alt?: string; - ariaLabel?: string; - x?: number; - y?: number; - w?: number; - h?: number; - frame?: { x?: number; y?: number; w?: number; h?: number }; -} - export const videoBlockExporter: PptxBlockExporter = { type: "video", exportability: "poster-with-link", async export(block: unknown, ctx: PptxExportContext): Promise<PptxBlockExport> { - const videoBlock = block as VideoBlock; - const content = videoBlock.content ?? {}; + const videoBlock = block as Block; + const frame = exportFrameOf(videoBlock); + if (!frame) { + return { + status: "unsupported", + issues: [frameErrorIssue(videoBlock.id, "video blocks require a resolved frame")], + }; + } + + const content = (videoBlock.content ?? {}) as VideoContent; const chapter = content.chapter ?? {}; const title = (typeof chapter.title === "string" ? chapter.title : "") || @@ -49,15 +48,11 @@ export const videoBlockExporter: PptxBlockExporter = { .map((point) => `\u2022 ${point}`) .join("\n"); const body = [summary, keyPoints].filter(Boolean).join("\n"); - const x = videoBlock.x ?? videoBlock.frame?.x ?? 0; - const y = videoBlock.y ?? videoBlock.frame?.y ?? 0; - const w = videoBlock.w ?? videoBlock.frame?.w ?? ctx.slideWidth * 0.5; - const h = videoBlock.h ?? videoBlock.frame?.h ?? ctx.slideHeight * 0.3; const theme = mapThemeColors(ctx.deck.theme); const svg = renderSnapshotSvg({ - width: Math.max(1, Math.round(w)), - height: Math.max(1, Math.round(h)), + width: Math.max(1, Math.round(frame.w)), + height: Math.max(1, Math.round(frame.h)), title, text: body, alt: videoBlock.alt ?? videoBlock.ariaLabel ?? "", @@ -82,12 +77,10 @@ export const videoBlockExporter: PptxBlockExporter = { ], element: { type: "svg", - x, - y, - w, - h, + elementId: videoBlock.id, + ...frame, data: { svg, alt }, }, }; }, -}; +}; \ No newline at end of file diff --git a/skills/deckforge/starter-components/export/pptx/pptx-assets.ts b/skills/deckforge/starter-components/export/pptx/pptx-assets.ts index 4a05ab3..35bcc69 100644 --- a/skills/deckforge/starter-components/export/pptx/pptx-assets.ts +++ b/skills/deckforge/starter-components/export/pptx/pptx-assets.ts @@ -1,4 +1,4 @@ -// starter-components/export/pptx/pptx-assets.ts +// export/pptx/pptx-assets.ts export interface AssetEmbedResult { dataUri: string; @@ -7,12 +7,38 @@ export interface AssetEmbedResult { mimeType: string; } -export async function embedAsset( +export interface EmbedOutcome { + result: AssetEmbedResult; + error?: string; +} + +const FETCH_TIMEOUT_MS = 15000; + +async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timer); + return response; + } catch (err) { + clearTimeout(timer); + throw err; + } +} + +/** + * Fetch one URL into an embeddable data URI, caching by URL so each source is + * resolved at most once per preparation. Unlike the legacy `embedAsset`, this + * variant also reports WHY a resolution failed so preflight can surface an + * actionable, block-specific blocking error instead of a generic one. + */ +export async function embedAssetDetailed( assetUrl: string, cache: Map<string, AssetEmbedResult> -): Promise<AssetEmbedResult> { +): Promise<EmbedOutcome> { if (cache.has(assetUrl)) { - return cache.get(assetUrl)!; + return { result: cache.get(assetUrl)! }; } if (assetUrl.startsWith("data:")) { @@ -21,11 +47,14 @@ export async function embedAsset( mimeType: assetUrl.split(";")[0].split(":")[1] ?? "image/png", }; cache.set(assetUrl, result); - return result; + return { result }; } try { - const response = await fetch(assetUrl); + const response = await fetchWithTimeout(assetUrl, FETCH_TIMEOUT_MS); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } const blob = await response.blob(); const mimeType = blob.type || "image/png"; @@ -40,15 +69,25 @@ export async function embedAsset( const result: AssetEmbedResult = { dataUri, mimeType }; cache.set(assetUrl, result); - return result; - } catch { - return { + return { result }; + } catch (err) { + const error = err instanceof Error ? err.message : "unknown error"; + const empty: AssetEmbedResult = { dataUri: "", mimeType: "image/png", }; + cache.set(assetUrl, empty); + return { result: empty, error }; } } +export async function embedAsset( + assetUrl: string, + cache: Map<string, AssetEmbedResult> +): Promise<AssetEmbedResult> { + return (await embedAssetDetailed(assetUrl, cache)).result; +} + export function embedAssetSync( assetUrl: string, cache: Map<string, AssetEmbedResult> @@ -57,4 +96,4 @@ export function embedAssetSync( return cache.get(assetUrl)!; } return null; -} \ No newline at end of file +} diff --git a/skills/deckforge/starter-components/export/pptx/pptx-context.ts b/skills/deckforge/starter-components/export/pptx/pptx-context.ts index 0d0ad1b..96469e1 100644 --- a/skills/deckforge/starter-components/export/pptx/pptx-context.ts +++ b/skills/deckforge/starter-components/export/pptx/pptx-context.ts @@ -1,26 +1,36 @@ -import type { PptxExportConfig, FontWarning } from "../export-types"; -import type { DeckProject } from "../../deck-types"; - -export interface PptxExportContextData { - deck: DeckProject; - config: PptxExportConfig; - fontWarnings: FontWarning[]; - assetCache: Map<string, { dataUri: string; width?: number; height?: number; mimeType: string }>; - slideWidth: number; - slideHeight: number; -} +import type { PptxExportConfig, PptxExportContext } from "../export-types"; +import type { DeckProject } from "../../deck/types"; +import { derivePptxSlideSize } from "../geometry"; +import type { PreparedExport } from "../prepare-export"; +/** + * Build the export context. The PPTX slide size is DERIVED from the actual + * document pixel size so the exported aspect ratio always equals the web + * aspect ratio (Phase 4). This context is the only place the document and the + * PPTX geometry relationship is established; individual exporters never invent + * their own mapping. + * + * The context carries the canonical asset registry from the single + * `prepareExport` phase when one was prepared — exporters consume resolved + * bytes from it and never fetch on their own. + */ export function createExportContext( deck: DeckProject, - config: PptxExportConfig -): PptxExportContextData { - const canvas = deck.canvas ?? { width: 13.333, height: 7.5 }; + config: PptxExportConfig, + prepared?: PreparedExport +): PptxExportContext { + const canvas = deck.canvas ?? { width: 1600, height: 900 }; + const slideWidth = canvas.width ?? 1600; + const slideHeight = canvas.height ?? 900; + const pptxSize = derivePptxSlideSize(slideWidth, slideHeight); return { deck, config, fontWarnings: [], - assetCache: new Map(), - slideWidth: canvas.width ?? 13.333, - slideHeight: canvas.height ?? 7.5, + assetRegistry: prepared?.assets ?? new Map(), + slideWidth, + slideHeight, + pptxWidth: pptxSize.width, + pptxHeight: pptxSize.height, }; } \ No newline at end of file diff --git a/skills/deckforge/starter-components/export/pptx/pptx-exporter.ts b/skills/deckforge/starter-components/export/pptx/pptx-exporter.ts index 383d66e..290284a 100644 --- a/skills/deckforge/starter-components/export/pptx/pptx-exporter.ts +++ b/skills/deckforge/starter-components/export/pptx/pptx-exporter.ts @@ -7,24 +7,25 @@ import type { FidelityReport, PptxBlockExport, PptxExportConfig, + PptxExportContext, PptxExportResult, PptxSlideElement, + PptxTextRun, } from "../export-types"; -import type { DeckProject, DeckSlide } from "../../deck-types"; +import type { DeckProject, DeckSlide, ChartContent } from "../../deck/types"; import { createExportContext } from "./pptx-context"; +import { prepareExport, isPreparedExport, type PreparedExport } from "../prepare-export"; import { getBlockExporter } from "./block-exporters/index"; +import { resolveSlideGeometry, type ResolvedBlockGeometry } from "../../deck/geometry-resolver"; import { verifyPptxArchive } from "./pptx-verifier"; -import { rawText } from "../fidelity/content-parity"; import { FIDELITY_POLICY } from "../fidelity/fidelity-policy"; import { planBlockRepresentation } from "../fidelity/representation-planner"; import { buildFidelityReport, fidelityStatus } from "../fidelity/fidelity-report"; import type { FidelityBlockReport } from "../fidelity/fidelity-types"; - -const PIXELS_PER_INCH = 96; - -function pixelsToInches(px: number): number { - return px / PIXELS_PER_INCH; -} +import type PptxGenJS from "pptxgenjs"; +import { derivePptxSlideSize, documentUnitToPptxInches } from "../geometry"; +import { validateExportScene, type ExportSceneDiagnostic } from "../export-scene"; +import { resolveTheme, hexToPptx } from "../resolved-theme"; async function toUint8Array(value: string | Blob | ArrayBuffer | Uint8Array): Promise<Uint8Array<ArrayBuffer>> { if (value instanceof ArrayBuffer) return new Uint8Array(value); @@ -33,72 +34,134 @@ async function toUint8Array(value: string | Blob | ArrayBuffer | Uint8Array): Pr return new Uint8Array(await value.arrayBuffer()); } -interface PptxAddCallable { - addText?: (...args: unknown[]) => void; - addImage?: (...args: unknown[]) => void; - addShape?: (...args: unknown[]) => void; - addTable?: (...args: unknown[]) => void; - addChart?: (...args: unknown[]) => void; - addNotes?: (...args: unknown[]) => void; +/** + * Normalize intrinsic pixel dimensions to a sub-100-inch representation that + * preserves the aspect ratio. pptxgenjs reads the element w/h as the source + * image size for crop math (and treats any value >= 100 as EMU, not inches), so + * the scale here is arbitrary but must keep both axes below 100. Only the ratio + * matters: `cover`/`contain` srcRect percentages are derived from it. + */ +function naturalAspectInches(width: number, height: number): { w: number; h: number } { + const max = Math.max(width, height); + if (!isFinite(max) || max <= 0) return { w: 0, h: 0 }; + const scale = 4 / max; + return { w: width * scale, h: height * scale }; } -function writeElementToSlide(pptxSlide: PptxAddCallable, element: PptxSlideElement): void { - // PptxGenJS uses inches; our deck model uses pixels (96 DPI). +/** + * Place one element on a PPTX slide. Element geometry is in DOCUMENT pixels; + * the slide is sized with the derived PPTX geometry, so each axis maps by pure + * ratio (Phase 5). No fixed pixels-per-inch constant: the relationship between + * document space and PPTX inches is established once in the geometry layer. + */ +async function writeElementToSlide( + pptxSlide: PptxGenJS.Slide, + element: PptxSlideElement, + ctx: PptxExportContext, +): Promise<void> { + if (!element || element.w <= 0 || element.h <= 0) return; + const opts = { - x: pixelsToInches(element.x), - y: pixelsToInches(element.y), - w: pixelsToInches(element.w), - h: pixelsToInches(element.h), + x: documentUnitToPptxInches(element.x, ctx.slideWidth, ctx.pptxWidth), + y: documentUnitToPptxInches(element.y, ctx.slideHeight, ctx.pptxHeight), + w: documentUnitToPptxInches(element.w, ctx.slideWidth, ctx.pptxWidth), + h: documentUnitToPptxInches(element.h, ctx.slideHeight, ctx.pptxHeight), }; switch (element.type) { case "text": { - const data = element.data as { text: string; options?: Record<string, unknown> }; - pptxSlide.addText?.(data.text, { ...opts, ...data.options }); + pptxSlide.addText(element.data.text, { + ...opts, + ...element.data.options, + } as unknown as PptxGenJS.TextPropsOptions); break; } case "image": { - const data = element.data as { dataUri: string; options?: Record<string, unknown> }; - pptxSlide.addImage?.({ data: data.dataUri }, { ...opts, ...data.options }); + // pptxgenjs computes the cover/contain `srcRect` crop from the element's + // w/h aspect (its `imgSize`) against the sizing box. The element's final + // size still comes from `sizing.w/h` (the frame), so we override w/h with + // the source image's intrinsic aspect — normalized to a sub-100-inch + // scale — so the crop matches the web `object-fit` instead of stretching. + const natural = + element.data.naturalWidth && element.data.naturalHeight + ? naturalAspectInches(element.data.naturalWidth, element.data.naturalHeight) + : null; + pptxSlide.addImage({ + data: element.data.dataUri, + altText: element.data.alt, + ...opts, + w: natural?.w ?? opts.w, + h: natural?.h ?? opts.h, + ...element.data.options, + } as unknown as PptxGenJS.ImageProps); break; } case "shape": { - const data = element.data as { shape: string; options?: Record<string, unknown> }; - pptxSlide.addShape?.(data.shape, { ...opts, ...data.options }); + pptxSlide.addShape(element.data.shape as PptxGenJS.SHAPE_NAME, { + ...opts, + ...element.data.options, + } as unknown as PptxGenJS.ShapeProps); break; } case "table": { - const data = element.data as { rows: unknown[][]; options?: Record<string, unknown> }; - pptxSlide.addTable?.(data.rows, { ...opts, ...data.options }); + pptxSlide.addTable(element.data.rows as unknown as PptxGenJS.TableRow[], { + ...opts, + ...element.data.options, + } as unknown as PptxGenJS.TableProps); break; } case "chart": { - const data = element.data as { chartType: string; data: unknown; options?: Record<string, unknown> }; - pptxSlide.addChart?.(data.chartType, data.data, { ...opts, ...data.options }); + pptxSlide.addChart(element.data.chartType as PptxGenJS.CHART_NAME, element.data.data as never, { + ...opts, + ...element.data.options, + } as unknown as PptxGenJS.IChartOpts); break; } case "fallback": { - const data = element.data as { text: string; options?: Record<string, unknown> }; - pptxSlide.addText?.(data.text, { ...opts, fill: { color: "FFF3CD" }, color: "856404", fontSize: 12 }); + // Use resolved theme colors for fallback elements + const theme = resolveTheme(ctx.deck); + pptxSlide.addText(element.data.text, { + ...opts, + fill: { color: hexToPptx(theme.tokens.surface) }, + color: hexToPptx(theme.tokens.muted), + fontSize: 12, + ...element.data.options, + } as unknown as PptxGenJS.TextPropsOptions); break; } case "svg": { - const data = element.data as { svg: string; options?: Record<string, unknown> }; - pptxSlide.addImage?.( - { data: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(data.svg)}` }, - { ...opts, ...data.options }, - ); + // The SVG fallback (charts, diagrams, video posters) must become a PNG. + // In Node there is no browser `Image`/`canvas` for PptxGenJS's built-in + // SVG preview, so the SVG is rasterized here with resvg to a crisp 2x PNG. + // In the browser that preview works, so the SVG data-URI is passed through + // and PptxGenJS rasterizes it client-side (the native resvg binding cannot + // run in the browser and is kept out of the browser bundle via a lazy + // import). + if (typeof document === "undefined") { + const { renderSvgToPng } = await import("../fidelity/svg/svg-raster"); + const png = renderSvgToPng(element.data.svg, element.w * 2); + pptxSlide.addImage({ + data: `data:image/png;base64,${png.toString("base64")}`, + altText: element.data.alt, + ...opts, + } as unknown as PptxGenJS.ImageProps); + } else { + // PptxGenJS requires a base64 header; it then keeps the SVG part and + // rasterizes a PNG preview client-side via canvas. + const bytes = new TextEncoder().encode(element.data.svg); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + pptxSlide.addImage({ + data: `data:image/svg+xml;base64,${btoa(binary)}`, + altText: element.data.alt, + ...opts, + } as unknown as PptxGenJS.ImageProps); + } break; } } } -/** - * Derive the overall export status from the accumulated issues, per-block - * statuses, and the fidelity-level status. A report can only be `complete` - * when every block exported natively, no warning/error-severity issue was - * recorded, and the content-parity gate passed. - */ export function deriveExportStatus( issues: ExportIssue[], slideReports: ExportSlideReport[], @@ -126,59 +189,20 @@ export interface ExportBuildResult { fidelity: FidelityReport; } -/** - * Resolve semantic slot frames for a slide so slot-positioned blocks get real - * coordinates. Falls back to the block's own frame or defaults. - */ -function resolveBlockFrames( - slide: DeckSlide, - canvas: DeckProject["canvas"] -): Map<string, { x: number; y: number; w: number; h: number }> { - const frameByBlockId = new Map<string, { x: number; y: number; w: number; h: number }>(); - - // Use layoutBindings to map blocks to their slot frames. - const bindings = slide.layoutBindings ?? []; - const safe = canvas.safeMargin ?? 64; - const w = canvas.width ?? 1600; - const h = canvas.height ?? 900; - const innerW = w - 2 * safe; - const innerH = h - 2 * safe; - - // Simple deterministic slot layout: distribute bound blocks vertically. - const slotCount = Math.max(1, bindings.length); - const slotHeight = innerH / slotCount; - const slotWidth = innerW; - - bindings.forEach((binding, index) => { - const frame = { - x: safe, - y: safe + index * slotHeight, - w: slotWidth, - h: slotHeight, - }; - for (const blockId of binding.blockIds) { - frameByBlockId.set(blockId, frame); - } - }); - - return frameByBlockId; -} - -/** - * Convert every slide/block into PPTX elements while recording per-block - * status and typed issues. Pure relative to the artifact generation so it can - * be exercised without a PPTX library. - */ export async function buildExportReport( - deck: DeckProject, + input: PreparedExport | DeckProject, config: PptxExportConfig ): Promise<ExportBuildResult> { - const ctx = createExportContext(deck, config); + const prepared: PreparedExport = isPreparedExport(input) + ? input + : await prepareExport(input, config); + const deck = prepared.deck; + const ctx = createExportContext(deck, config, prepared); const issues: ExportIssue[] = []; const slideReports: ExportSlideReport[] = []; const slides: ExportBuildResult["slides"] = []; - for (const slide of deck.slides ?? []) { + for (const slide of deck.slides) { if (!config.includeHiddenSlides && slide.hidden) { issues.push({ code: "hidden-slide-skipped", @@ -194,10 +218,22 @@ export async function buildExportReport( const blockReports: ExportBlockReport[] = []; const elements: PptxSlideElement[] = []; - // Resolve semantic slot frames so slot-positioned blocks get real coordinates. - const frameByBlockId = resolveBlockFrames(slide, deck.canvas); + const scene = resolveSlideGeometry(slide, deck.canvas); + const frameByBlockId = scene.frameByBlockId; + + const slotGroups = new Map<string, ResolvedBlockGeometry[]>(); + for (const entry of scene.blocks) { + if (!entry.slotId) continue; + const group = slotGroups.get(entry.slotId) ?? []; + group.push(entry); + slotGroups.set(entry.slotId, group); + } + + const processedBlockIds = new Set<string>(); + + for (const block of slide.blocks) { + if (processedBlockIds.has(block.id)) continue; - for (const block of slide.blocks ?? []) { let result: PptxBlockExport; if (block.hidden) { @@ -216,10 +252,33 @@ export async function buildExportReport( } else { const exporter = getBlockExporter(block.type); try { - // Attach the resolved slot frame to the block so exporters use real coordinates. const resolvedFrame = frameByBlockId.get(block.id); - const blockWithFrame = resolvedFrame - ? { ...block, frame: { ...(block.frame ?? {}), ...resolvedFrame } } + let adjustedFrame = resolvedFrame + ? { ...resolvedFrame } + : undefined; + + if (resolvedFrame) { + const entry = scene.blocks.find((candidate) => candidate.blockId === block.id); + if (entry?.slotId) { + const group = slotGroups.get(entry.slotId) ?? [entry]; + const blockIndex = group.indexOf(entry); + const blocksInSlot = group.length; + if (blocksInSlot > 1) { + const gap = 12; + const availableH = resolvedFrame.h - gap * (blocksInSlot - 1); + const slotH = Math.max(40, Math.floor(availableH / blocksInSlot)); + adjustedFrame = { + x: resolvedFrame.x, + y: resolvedFrame.y + blockIndex * (slotH + gap), + w: resolvedFrame.w, + h: slotH, + }; + } + } + } + + const blockWithFrame = adjustedFrame + ? { ...block, frame: { ...block.frame, ...adjustedFrame }, resolvedFrame: adjustedFrame } : block; result = await exporter.export(blockWithFrame, ctx); } catch (err) { @@ -258,14 +317,85 @@ export async function buildExportReport( ); blockReports.push(planned); issues.push(...stampedIssues); - if (result.element) elements.push(result.element); + const emitted = result.elements?.length + ? result.elements + : result.element + ? [result.element] + : []; + for (const element of emitted) { + if (element.w > 0 && element.h > 0) { + elements.push(element); + } + } } slideReports.push({ slideId: slide.id, blocks: blockReports }); slides.push({ slide, elements }); } - const exportedSlides = (deck.slides ?? []).filter( + // ── Chart invariants ──────────────────────────────────────────────────────── + // "New chart" templates are NEVER counted: the source set is the visible, + // non-template chart blocks that carry real data. The exported set is every + // element (native chart OR SVG fidelity fallback) whose elementId maps back + // to one of those source blocks. Every exported chart MUST have a + // sourceBlockId; a chart with no source block is an orphan and is rejected. + const sourceChartBlockIds = new Set<string>(); + for (const slide of deck.slides) { + if (!config.includeHiddenSlides && slide.hidden) continue; + for (const block of slide.blocks) { + if (block.type !== "chart") continue; + const content = block.content as ChartContent | undefined; + if (content?.isTemplate) continue; + if (!Array.isArray(content?.values) || !content.values.length) continue; + sourceChartBlockIds.add(block.id); + } + } + + const allExportedElements = slides.flatMap(({ elements }) => elements); + const exportedChartCount = allExportedElements.filter( + (element) => !!element.elementId && sourceChartBlockIds.has(element.elementId), + ).length; + + if (sourceChartBlockIds.size !== exportedChartCount) { + issues.push({ + code: "chart-count-mismatch", + severity: "error", + message: `Chart count mismatch: ${sourceChartBlockIds.size} source charts but ${exportedChartCount} exported charts`, + automaticFixAvailable: false, + }); + } + + // Every native chart element must originate from a real source chart block. + const orphanCharts = allExportedElements.filter( + (element) => + element.type === "chart" && + !(element.elementId && sourceChartBlockIds.has(element.elementId)), + ); + if (orphanCharts.length > 0) { + issues.push({ + code: "chart-count-mismatch", + severity: "error", + message: `Exported ${orphanCharts.length} chart element(s) with no matching source chart block`, + automaticFixAvailable: false, + }); + } + + const sceneDiagnostics = validateExportScene( + { slides: slides.map(({ slide, elements }) => ({ slideId: slide.id, elements })) }, + ctx, + ); + for (const diagnostic of sceneDiagnostics) { + issues.push({ + code: diagnostic.code, + severity: diagnostic.severity === "error" ? "error" : "warning", + slideId: diagnostic.slideId, + blockId: diagnostic.elementId, + message: diagnostic.message, + automaticFixAvailable: false, + }); + } + + const exportedSlides = deck.slides.filter( (slide) => config.includeHiddenSlides || !slide.hidden, ); const fidelityBlocks = slideReports.flatMap((slide) => slide.blocks); @@ -295,27 +425,39 @@ export class PptxExporter { this.config = config; } - async export(deck: DeckProject): Promise<PptxExportResult> { - const { report, slides, fidelity } = await buildExportReport(deck, this.config); + async export(input: PreparedExport | DeckProject): Promise<PptxExportResult> { + // The canonical, single preparation phase. When a PreparedExport is passed + // (as the dialog always does), no resolution work happens here — the + // exporter consumes the already-resolved registry. A raw DeckProject is + // prepared on the fly for programmatic callers. + const prepared: PreparedExport = isPreparedExport(input) + ? input + : await prepareExport(input, this.config); + const deck = prepared.deck; + + const { report, slides, fidelity } = await buildExportReport(prepared, this.config); const PptxGenJS = (await import("pptxgenjs")).default; const pptx = new PptxGenJS(); - const canvas = deck.canvas ?? { width: 13.333, height: 7.5 }; - const slideWidthInches = pixelsToInches(canvas.width ?? 13.333); - const slideHeightInches = pixelsToInches(canvas.height ?? 7.5); - pptx.defineLayout({ name: "CUSTOM", width: slideWidthInches, height: slideHeightInches }); + // Phase 4: PPTX slide size is DERIVED from the document pixels so the + // exported aspect ratio always matches the web canvas (no hard-coded + // 13.333"x7.5" that would distort e.g. a 1920x800 "wide" canvas). + const canvas = deck.canvas ?? { width: 1600, height: 900 }; + const pptxSize = derivePptxSlideSize(canvas.width ?? 1600, canvas.height ?? 900); + pptx.defineLayout({ name: "CUSTOM", width: pptxSize.width, height: pptxSize.height }); pptx.layout = "CUSTOM"; for (const { slide, elements } of slides) { - const pptxSlide = pptx.addSlide() as PptxAddCallable; + const pptxSlide = pptx.addSlide(); if (slide.speakerNotes && this.config.includeSpeakerNotes) { - pptxSlide.addNotes?.(slide.speakerNotes); + pptxSlide.addNotes(slide.speakerNotes); } + const slideCtx = createExportContext(deck, this.config, prepared); for (const element of elements) { - writeElementToSlide(pptxSlide, element); + await writeElementToSlide(pptxSlide, element, slideCtx); } } @@ -326,14 +468,51 @@ export class PptxExporter { type: "application/vnd.openxmlformats-officedocument.presentationml.presentation", }); - const expectedTexts = (slides ?? []) - .flatMap(({ slide }) => (slide.blocks ?? []).filter((block) => !block.hidden).map((block) => rawText(block))) - .filter((text) => text.length > 0); - const expectedNotes = slides.filter( - ({ slide }) => this.config.includeSpeakerNotes && !!slide.speakerNotes, - ).length; - - const verification = await verifyPptxArchive({ report, blob, expectedTexts, expectedNotes }); + // Semantic native-text corpus: the EXACT text the exporter wrote into each + // native text/fallback/shape element. Verifying this (rather than a rawText + // reconstruction) is what makes text-survival meaningful — bullets keep + // their "•" prefixes, process steps their shape text, etc. + const nativeTextExpected = slides.flatMap(({ elements }) => + elements + .filter((element) => element.type === "text" || element.type === "fallback" || element.type === "shape") + .map((element) => { + if (element.type === "shape") { + const text = (element.data.options as { text?: unknown } | undefined)?.text; + return typeof text === "string" ? text : ""; + } + const data = element.data as { text?: string | PptxTextRun[] }; + return Array.isArray(data.text) + ? data.text.map((run) => run.text ?? "").join(" ") + : (data.text ?? ""); + }) + .filter((text) => text.length > 0), + ); + + // Semantic visual-fallback corpus: alt/description on SVG/raster elements. + // These survive as element attributes in the slide XML, not as <a:t> runs. + const visualFallbackTexts = slides.flatMap(({ elements }) => + elements + .filter((element) => element.type === "svg" || element.type === "image") + .flatMap((element) => { + const alt = (element.data as { alt?: string }).alt; + return alt && alt.length > 0 ? [alt] : []; + }), + ); + + // pptxgenjs always emits one notesSlide part per exported slide, even when + // the slide has no speaker notes. The speaker-notes structural check must + // therefore expect one notes part per slide, not only for slides that + // happen to carry notes (which would fail every export of a no-notes deck). + const expectedNotes = this.config.includeSpeakerNotes ? slides.length : 0; + + const verification = await verifyPptxArchive({ + report, + blob, + nativeTextExpected, + visualFallbackTexts, + expectedNotes, + includeSpeakerNotes: this.config.includeSpeakerNotes, + }); const archiveVerified = verification.passed; const allIssues: ExportIssue[] = [...report.issues]; @@ -354,4 +533,4 @@ export class PptxExporter { return { report, blob, archiveVerified, fidelity }; } -} \ No newline at end of file +} diff --git a/skills/deckforge/starter-components/export/pptx/pptx-fallback-renderer.ts b/skills/deckforge/starter-components/export/pptx/pptx-fallback-renderer.ts index 8e33fec..fe62657 100644 --- a/skills/deckforge/starter-components/export/pptx/pptx-fallback-renderer.ts +++ b/skills/deckforge/starter-components/export/pptx/pptx-fallback-renderer.ts @@ -1,4 +1,4 @@ -// starter-components/export/pptx/pptx-fallback-renderer.ts +// export/pptx/pptx-fallback-renderer.ts import type { PptxExportContext, PptxSlideElement } from "../export-types"; @@ -8,13 +8,14 @@ export async function renderFallback( reason: string ): Promise<PptxSlideElement> { const blockType = (block.type as string) ?? "unknown"; + const frame = (block.frame as { x?: number; y?: number; w?: number; h?: number } | undefined) ?? {}; return { type: "fallback", - x: (block.x as number) ?? 0, - y: (block.y as number) ?? 0, - w: (block.w as number) ?? ctx.slideWidth * 0.5, - h: (block.h as number) ?? ctx.slideHeight * 0.3, + x: (block.x as number) ?? frame.x ?? 0, + y: (block.y as number) ?? frame.y ?? 0, + w: (block.w as number) ?? frame.w ?? ctx.slideWidth * 0.5, + h: (block.h as number) ?? frame.h ?? ctx.slideHeight * 0.3, data: { text: `[${blockType}: ${reason}]`, options: { diff --git a/skills/deckforge/starter-components/export/pptx/pptx-fonts.ts b/skills/deckforge/starter-components/export/pptx/pptx-fonts.ts index 50b395b..2a3d411 100644 --- a/skills/deckforge/starter-components/export/pptx/pptx-fonts.ts +++ b/skills/deckforge/starter-components/export/pptx/pptx-fonts.ts @@ -1,4 +1,4 @@ -// starter-components/export/pptx/pptx-fonts.ts +// export/pptx/pptx-fonts.ts import type { FontWarning } from "../export-types"; @@ -16,6 +16,32 @@ const PPTX_SAFE_FONTS = new Set([ "Times New Roman", "Trebuchet MS", "Verdana", ]); +/** + * Registry of web font families (as used by the deck themes) to their closest + * PPT-safe substitutes. Kept in sync with deck/themes.ts so exported slides + * stay visually coherent even when the web font is unavailable in PowerPoint. + */ +const WEB_TO_SUBSTITUTES: Record<string, string> = { + "Inter": "Arial", + "Manrope": "Arial", + "IBM Plex Sans": "Arial", + "Sora": "Arial", + "Libre Baskerville": "Georgia", + "JetBrains Mono": "Consolas", +}; + +/** + * Resolve a web/theme font to a PPT-safe family. Returns the input unchanged + * when it is already PPT-safe. + */ +export function resolvePptxFont(fontFamily: string): string { + if (!fontFamily) return "Arial"; + const cleanName = fontFamily.replace(/['"]/g, "").trim().split(",")[0].trim(); + if (PPTX_SAFE_FONTS.has(cleanName)) return cleanName; + if (WEB_TO_SUBSTITUTES[cleanName]) return WEB_TO_SUBSTITUTES[cleanName]; + return "Arial"; +} + export function checkFontCompatibility( fontFamily: string, slideId?: string, @@ -35,7 +61,7 @@ export function checkFontCompatibility( fontFamily: cleanName, slideId, blockId, - substituteFont: "Arial", + substituteFont: resolvePptxFont(cleanName), }; } @@ -54,4 +80,4 @@ export function collectFontWarnings(deck: { slides?: Array<{ id?: string; blocks } return warnings; -} \ No newline at end of file +} diff --git a/skills/deckforge/starter-components/export/pptx/pptx-theme.ts b/skills/deckforge/starter-components/export/pptx/pptx-theme.ts index 1f09558..c853e8e 100644 --- a/skills/deckforge/starter-components/export/pptx/pptx-theme.ts +++ b/skills/deckforge/starter-components/export/pptx/pptx-theme.ts @@ -1,5 +1,4 @@ -import type { DeckProject } from "../../deck-types"; -import type PptxGenJS from "pptxgenjs"; +import type { DeckProject } from "../../deck/types"; type DeckTheme = DeckProject["theme"]; @@ -43,14 +42,3 @@ export function mapThemeFonts(theme: DeckTheme): { heading: string; body: string body: typography.bodyFont ?? "Arial", }; } - -export function applyThemeToPptx(pptx: PptxGenJS, theme: DeckTheme): void { - const fonts = mapThemeFonts(theme); - - // PptxGenJS ThemeProps only supports font faces; theme colors are applied - // per-element via mapThemeColors() in the block exporters. - pptx.theme = { - headFontFace: fonts.heading, - bodyFontFace: fonts.body, - }; -} diff --git a/skills/deckforge/starter-components/export/pptx/pptx-verifier.ts b/skills/deckforge/starter-components/export/pptx/pptx-verifier.ts index 0177fb2..497bc97 100644 --- a/skills/deckforge/starter-components/export/pptx/pptx-verifier.ts +++ b/skills/deckforge/starter-components/export/pptx/pptx-verifier.ts @@ -4,10 +4,23 @@ import type { ExportReport, PptxVerificationCheck, PptxVerificationReport } from export interface VerificationInput { report: ExportReport; blob: Blob; - /** Text fragments that must survive somewhere in the archive's slide <a:t> runs. */ + /** Legacy alias for native text that must survive in slide <a:t> runs. */ expectedTexts?: string[]; + /** + * Semantic native-text corpus: text fragments from blocks exported as + * native text elements. EVERY fragment must survive in <a:t> runs. + */ + nativeTextExpected?: string[]; + /** + * Semantic visual-fallback corpus: alt/description fragments from blocks + * exported as SVG/raster elements. These must survive in the slide XML + * (as element attributes such as `descr`), not necessarily in <a:t> runs. + */ + visualFallbackTexts?: string[]; /** Number of speaker-notes parts expected (slides with notes in this export). */ expectedNotes?: number; + /** When false the speaker-notes check is NOT APPLICABLE and always passes. */ + includeSpeakerNotes?: boolean; } function decode(entryText: string): string { @@ -25,6 +38,15 @@ function normalizeText(text: string): string { return decode(text).replace(/<[^>]*>/g, " ").replace(/ /g, " ").replace(/\s+/g, " ").trim().toLowerCase(); } +/** + * Collapse whitespace over the DECODED XML including attribute values, so a + * phrase stored in an attribute (e.g. `descr="..."` alt text on an image or + * SVG element) can be located without needing to parse the XML. + */ +function normalizeXmlCollapsed(text: string): string { + return decode(text).replace(/ /g, " ").replace(/\s+/g, " ").trim().toLowerCase(); +} + function slidePartName(name: string): boolean { return /^ppt\/slides\/slide\d+\.xml$/.test(name); } @@ -41,9 +63,18 @@ export async function verifyPptxArchive(input: VerificationInput): Promise<{ passed: boolean; report: PptxVerificationReport; }> { - const { report, blob, expectedTexts = [], expectedNotes } = input; + const { + report, + blob, + expectedTexts = [], + nativeTextExpected = [], + visualFallbackTexts = [], + expectedNotes, + includeSpeakerNotes = true, + } = input; const checks: PptxVerificationCheck[] = []; const expectedSlides = report.slides.length; + const nativeCorpus = [...expectedTexts, ...nativeTextExpected].filter((text) => text && text.length > 0); try { const zipData = @@ -58,34 +89,63 @@ export async function verifyPptxArchive(input: VerificationInput): Promise<{ }); const notesCount = Object.keys(zip.files).filter(notesPartName).length; - const notesExpected = expectedNotes ?? expectedSlides; - checks.push({ - name: "speaker-notes", - passed: notesCount === notesExpected, - detail: `expected notes for ${notesExpected} slides, found ${notesCount}`, - }); + if (includeSpeakerNotes === false) { + // Regression (P2-002): speaker-notes is NOT APPLICABLE when the user + // disabled notes; it must not be compared against an expectation. + checks.push({ + name: "speaker-notes", + passed: true, + detail: "not-applicable: speaker notes disabled", + }); + } else { + const notesExpected = expectedNotes ?? expectedSlides; + checks.push({ + name: "speaker-notes", + passed: notesCount === notesExpected, + detail: `expected notes for ${notesExpected} slides, found ${notesCount}`, + }); + } const slideTexts: string[] = []; + const slideXmlCollapsed: string[] = []; for (const name of archiveSlides) { const entry = zip.file(name); if (!entry) continue; const raw = await entry.async("string"); const texts = raw.match(/<a:t>([^<]*)<\/a:t>/g) ?? []; slideTexts.push(...texts.map((t) => t.replace(/<\/?a:t>/g, ""))); + slideXmlCollapsed.push(normalizeXmlCollapsed(raw)); } const combined = normalizeText(slideTexts.join(" ")); - const missing: string[] = []; - for (const expected of expectedTexts) { + const missingNative: string[] = []; + for (const expected of nativeCorpus) { const normalized = normalizeText(expected); if (normalized && !combined.includes(normalized)) { - missing.push(`missing text: "${expected}"`); + missingNative.push(`missing text: "${expected}"`); } } checks.push({ name: "text-survival", - passed: missing.length === 0, - detail: missing.length === 0 ? "all expected text found" : missing.join("; "), + passed: missingNative.length === 0, + detail: missingNative.length === 0 ? "all expected text found" : missingNative.join("; "), + }); + + const collapsed = slideXmlCollapsed.join(" "); + const missingFallback: string[] = []; + for (const expected of visualFallbackTexts) { + const normalized = normalizeXmlCollapsed(expected); + if (normalized && !collapsed.includes(normalized)) { + missingFallback.push(`missing alt/description: "${expected}"`); + } + } + checks.push({ + name: "visual-fallback-alt", + passed: missingFallback.length === 0, + detail: + missingFallback.length === 0 + ? "all fallback alt/description text found" + : missingFallback.join("; "), }); const missingRels = archiveSlides.filter((name) => !zip.file(relsPartName(name))); From a21fe30dd5a3ffe2eb0ad7dc5a3213d33e8368eb Mon Sep 17 00:00:00 2001 From: tph-kds <hungcompo123@gmail.com> Date: Sat, 15 Aug 2026 03:56:36 +0700 Subject: [PATCH 05/16] refactor(scaffold): convert deck-types.ts to re-export shim over vendored deck types --- .../starter-components/deck-types.ts | 155 ++++-------------- 1 file changed, 30 insertions(+), 125 deletions(-) diff --git a/skills/deckforge/starter-components/deck-types.ts b/skills/deckforge/starter-components/deck-types.ts index 3edbdcb..65fae85 100644 --- a/skills/deckforge/starter-components/deck-types.ts +++ b/skills/deckforge/starter-components/deck-types.ts @@ -3,9 +3,6 @@ export type SlideId = string; export type BlockId = string; export type InteractionId = string; -export type Frame = { x: number; y: number; w: number; h: number; rotation?: number; z?: number }; -export type PositionMode = 'slot' | 'flow' | 'freeform' | 'background'; - export type BuildAnimation = { id: string; trigger?: 'on-enter' | 'on-click' | 'with-previous' | 'after-previous' | 'on-hover' | 'on-visible'; @@ -16,135 +13,43 @@ export type BuildAnimation = { reducedMotionFallback?: string; }; -export type DeckBlock = { - id: BlockId; - type: string; - content?: unknown; - slot?: string; - positionMode?: PositionMode; - frame?: Frame; - resolvedFrame?: Frame; - fitPolicy?: 'wrap' | 'contain' | 'cover' | 'scroll' | 'change-layout' | 'split-slide'; - style?: Record<string, unknown>; - data?: unknown; - alt?: string; - ariaLabel?: string; - sourceIds?: string[]; - animation?: BuildAnimation; - locked?: boolean; - hidden?: boolean; - decorative?: boolean; - allowOverlap?: boolean; - groupId?: string; - role?: string; -}; - -export type LayoutBinding = { slot: string; blockIds: BlockId[]; flow?: 'stack' | 'row' | 'grid' | 'overlay'; gap?: number }; +export type DeckBlock = Block; -export type Block = DeckBlock; - -export type DeckInteraction = { - id: InteractionId; - type: string; - trigger: string; - targetId?: string; - action: string; - payload?: unknown; +export type DeckInteraction = SlideInteraction & { audienceVisible?: boolean; requiresNetwork?: boolean; fallback?: string; - ariaLabel?: string; -}; - -export type DeckSlide = { - id: SlideId; - title: string; - layout: string; - layoutVariant?: string; - layoutBindings?: LayoutBinding[]; - density?: 'low' | 'medium' | 'high'; - focalBlockId?: BlockId; - blocks: DeckBlock[]; - speakerNotes?: string; - sources?: string[]; - interactions?: DeckInteraction[]; - hidden?: boolean; - section?: string; - transition?: string; - durationMs?: number; -}; - -export type DeckProject = { - schemaVersion: '2.1'; - experience: { - profile: 'editable-deck' | 'presentation-runtime' | 'published-story' | 'embedded-deck'; - surfaces: Array<'editor' | 'presenter' | 'viewer' | 'embed-viewer'>; - routes?: Record<string, string>; - capabilities?: string[]; - }; - meta: { - id: DeckId; - slug: string; - title: string; - language: string; - description?: string; - audience?: string; - objective?: string; - templateId?: string; - }; - canvas: { - aspectRatio: '16:9' | '4:3' | 'custom'; - width: number; - height: number; - safeMargin?: number; - grid?: number; - responsiveMode?: 'letterbox' | 'reflow' | 'hybrid'; - layoutMode?: 'semantic-slots' | 'hybrid' | 'freeform'; - }; - theme: { id: string; overrides?: Record<string, unknown>; designSystemRef?: string }; - presentation: { - mode: 'horizontal' | 'vertical' | 'freeform' | '3d-coverflow'; - transition: string; - keyboard: boolean; - touch?: boolean; - deepLinks?: boolean; - overview?: boolean; - speakerView?: boolean; - progress?: boolean; - controls?: boolean; - reducedMotion: 'respect-system' | 'always' | 'never'; - motionProfileId?: string; - defaultBuilds?: boolean; - }; - editor: { - enabled: boolean; - toolbar: boolean; - history: boolean; - sidePanel?: boolean; - assetLibrary?: boolean; - themePicker?: boolean; - layoutPicker?: boolean; - shortcutHelp?: boolean; - saveStatus?: boolean; - persistence?: 'none' | 'local-storage' | 'api' | 'host-managed'; - snapToGrid?: boolean; - guides?: boolean; - comments?: boolean; - collaboration?: boolean; - autosave?: boolean; - commandPalette?: boolean; - notes?: boolean; - allowedBlockTypes?: string[]; - requiredZones?: string[]; - }; - shortcuts?: { helpEnabled?: boolean; helpKey?: string; editorPreset?: string; presenterPreset?: string }; - slides: DeckSlide[]; - sources?: Array<{ id: string; title: string; url: string }>; - publish: { visibility: 'private' | 'workspace' | 'unlisted' | 'public'; embed: { enabled: boolean; allowedOrigins?: string[]; sandbox?: string[]; responsive?: boolean } }; }; export type EditorSelection = { slideId: SlideId; blockIds: BlockId[]; mode?: 'block' | 'text' | 'canvas' }; -export type SaveState = 'clean' | 'dirty' | 'saving' | 'saved' | 'failed' | 'offline' | 'conflict'; + +export type { + PositionMode, + FitPolicy, + Frame, + BlockAnimation, + BlockStyle, + ChartValue, + ChartContent, + AssetKind, + DeckAsset, + ImageBlockContent, + MetricContent, + ProcessStep, + Block, + LayoutBinding, + SlideInteraction, + DeckSlide, + SourceRef, + ThemeTokens, + ThemeGradients, + ThemeDef, + DeckProject, + SaveState, + Route, + PresenterBuildState, + RenderBlockProps, +} from './deck/types'; export type { ExportIssueSeverity, From 1614c46e3cf6b5cfdf59ac90929c1429b45d8eb6 Mon Sep 17 00:00:00 2001 From: tph-kds <hungcompo123@gmail.com> Date: Sat, 15 Aug 2026 04:07:13 +0700 Subject: [PATCH 06/16] feat(scaffold): rebuild export barrel and add export-coverage audit --- .../scripts/audit_scaffold_exports.py | 50 ++++++++ .../starter-components/export/index.ts | 118 +++++++++++++++--- 2 files changed, 152 insertions(+), 16 deletions(-) create mode 100644 skills/deckforge/scripts/audit_scaffold_exports.py diff --git a/skills/deckforge/scripts/audit_scaffold_exports.py b/skills/deckforge/scripts/audit_scaffold_exports.py new file mode 100644 index 0000000..72dda0a --- /dev/null +++ b/skills/deckforge/scripts/audit_scaffold_exports.py @@ -0,0 +1,50 @@ +"""Audit that export/index.ts re-exports every public symbol of the scaffold export/ modules.""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +BARREL = ROOT / "skills" / "deckforge" / "starter-components" / "export" / "index.ts" +EXPORT_DIR = BARREL.parent +MODULES = ("export-types.ts", "export-preflight.ts", "export-dialog.tsx", + "snapshot.ts", "prepare-export.ts", "self-contained.ts", + "resolved-theme.ts", "geometry.ts", "image-dimensions.ts", + "export-scene.ts", "fidelity/content-parity.ts", + "fidelity/fidelity-policy.ts", "fidelity/fidelity-report.ts", + "fidelity/fidelity-types.ts", "fidelity/representation-planner.ts", + "fidelity/svg/svg-chart.ts", "fidelity/svg/svg-diagram.ts", + "fidelity/svg/svg-raster.ts", "fidelity/svg/svg-snapshot.ts", + "pptx/pptx-exporter.ts", "pptx/pptx-verifier.ts", "pptx/pptx-context.ts", + "pptx/pptx-theme.ts", "pptx/pptx-fonts.ts", "pptx/pptx-assets.ts", + "pptx/pptx-fallback-renderer.ts", "pptx/pptx-placeholder.ts", + "pptx/export-utils.ts", "pptx/block-exporters/chart.ts", + "pptx/block-exporters/diagram.ts", "pptx/block-exporters/fallback.ts", + "pptx/block-exporters/image.ts", "pptx/block-exporters/index.ts", + "pptx/block-exporters/process.ts", "pptx/block-exporters/shape.ts", + "pptx/block-exporters/table.ts", "pptx/block-exporters/text.ts", + "pptx/block-exporters/video.ts") + +SYMBOL = re.compile(r"^export\s+(?:type\s+)?(?:declare\s+)?(?:abstract\s+)?(?:async\s+)?(?:function|const|class|interface|type|enum|var|let)\s+([A-Za-z_$][\w$]*)", re.MULTILINE) + +def exported_symbols(path: Path) -> set[str]: + return {m.group(1) for m in SYMBOL.finditer(path.read_text(encoding="utf-8"))} + +def main() -> int: + barrel_text = BARREL.read_text(encoding="utf-8") + missing: list[str] = [] + for rel in MODULES: + for sym in exported_symbols(EXPORT_DIR / rel): + if not re.search(rf"\b{re.escape(sym)}\b", barrel_text): + missing.append(f"{rel}: {sym}") + if missing: + print("Barrel is missing exports for:") + for item in missing: + print(f" {item}") + return 1 + print(f"OK: barrel re-exports all symbols from {len(MODULES)} modules") + return 0 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/deckforge/starter-components/export/index.ts b/skills/deckforge/starter-components/export/index.ts index d4d4082..eb5fd2d 100644 --- a/skills/deckforge/starter-components/export/index.ts +++ b/skills/deckforge/starter-components/export/index.ts @@ -13,34 +13,123 @@ export type { PptxExportContext, PptxExportability, PptxSlideElement, + PptxSlideElementType, PptxBlockExport, PptxBlockExporter, PptxExportResult, + PptxTextRun, FontWarning, ExportDialogProps, BlockRepresentation, PptxVerificationCheck, PptxVerificationReport, FidelityReport, + ExportCoverage, + PreflightGroupSummary, + PreflightIssueGroup, } from "./export-types"; export { DEFAULT_PPTX_CONFIG } from "./export-types"; +export { + isUsableFrame, + aspectOf, + aspectMatches, + derivePptxSlideSize, + documentRectToPptxRect, + documentUnitToPptxInches, + browserFontSizeToPptPt, + fontSizeFromCqw, + validateFrame, + validateRectWithinSlide, +} from "./geometry"; +export type { Rect, Size } from "./geometry"; + +export { + resolveSlideSnapshot, + resolveChartSpecForBlock, + createDeckSnapshot, + validateSnapshot, + hashSlideSemanticContent, +} from "./snapshot"; +export type { + ImmutableSlideSnapshot, + ResolvedAssetSnapshot, + ResolvedBlockSnapshot, + ResolvedChartSpec, + ResolvedChartStyle, + ResolvedPaint, + ResolvedTextStyle, + ResolvedThemeSnapshot, +} from "./snapshot"; + +export { prepareExport, isPreparedExport } from "./prepare-export"; +export type { PreparedExport, PreparedAsset, PreparedAssetStatus } from "./prepare-export"; + +export { makeDeckSelfContained } from "./self-contained"; +export type { EmbedFn, SelfContainedFailure, SelfContainedResult } from "./self-contained"; + +export { + resolveTheme, + normalizeColor, + hexToRgb, + hexToPptx, + resolvePptxFont, + isPptxSafeFont, + resolveChartColors, + resolveTextColor, +} from "./resolved-theme"; +export type { ResolvedTheme } from "./resolved-theme"; + +export { readImageSizeFromDataUri } from "./image-dimensions"; +export type { IntrinsicImageSize } from "./image-dimensions"; + +export { validateExportScene, sceneHasErrors } from "./export-scene"; +export type { ExportScene, ExportSceneDiagnostic, SceneSeverity } from "./export-scene"; + +export { renderChartToSvg } from "./fidelity/svg/svg-chart"; +export { renderSvgToPng } from "./fidelity/svg/svg-raster"; +export { renderDiagramSvg, normalizeDiagram } from "./fidelity/svg/svg-diagram"; +export type { DiagramInput, DiagramNodeInput, DiagramEdgeInput, DiagramSvgOptions } from "./fidelity/svg/svg-diagram"; +export { renderSnapshotSvg } from "./fidelity/svg/svg-snapshot"; +export type { SnapshotSvgOptions } from "./fidelity/svg/svg-snapshot"; + +export { PLACEHOLDER_IMAGE_DATA_URI } from "./pptx/pptx-placeholder"; + +export { + exportFrameOf, + frameErrorIssue, + frameValidation, + browserTypographyFor, + fontSizeToPpt, + pptFontFor, + textFrameOptions, + estimateTextHeightPx, +} from "./pptx/export-utils"; +export type { BrowserTypography } from "./pptx/export-utils"; + export { PptxExporter, buildExportReport, deriveExportStatus } from "./pptx/pptx-exporter"; +export type { ExportBuildResult } from "./pptx/pptx-exporter"; export { verifyPptxArchive } from "./pptx/pptx-verifier"; +export type { VerificationInput } from "./pptx/pptx-verifier"; export { createExportContext } from "./pptx/pptx-context"; -export { mapThemeColors, mapThemeFonts, applyThemeToPptx } from "./pptx/pptx-theme"; +export { mapThemeColors, mapThemeFonts } from "./pptx/pptx-theme"; export { checkFontCompatibility, collectFontWarnings } from "./pptx/pptx-fonts"; -export { embedAsset, embedAssetSync } from "./pptx/pptx-assets"; +export { embedAsset, embedAssetDetailed, embedAssetSync } from "./pptx/pptx-assets"; +export type { AssetEmbedResult, EmbedOutcome } from "./pptx/pptx-assets"; export { renderFallback } from "./pptx/pptx-fallback-renderer"; -export { - blockExporters, - getBlockExporter, - getExportability, -} from "./pptx/block-exporters/index"; +export { blockExporters, getBlockExporter, getExportability } from "./pptx/block-exporters/index"; -export { textBlockExporter, headingBlockExporter, bulletsBlockExporter, calloutBlockExporter, citationBlockExporter, metricBlockExporter, processBlockExporter } from "./pptx/block-exporters/text"; +export { + textBlockExporter, + headingBlockExporter, + bulletsBlockExporter, + calloutBlockExporter, + citationBlockExporter, + metricBlockExporter, +} from "./pptx/block-exporters/text"; +export { processBlockExporter } from "./pptx/block-exporters/process"; export { imageBlockExporter } from "./pptx/block-exporters/image"; export { shapeBlockExporter } from "./pptx/block-exporters/shape"; export { tableBlockExporter } from "./pptx/block-exporters/table"; @@ -52,18 +141,15 @@ export { fallbackBlockExporter } from "./pptx/block-exporters/fallback"; export { FIDELITY_POLICY } from "./fidelity/fidelity-policy"; export { calculateContentParity, rawText } from "./fidelity/content-parity"; export { planBlockRepresentation, countRepresentation } from "./fidelity/representation-planner"; +export type { PlannerInput } from "./fidelity/representation-planner"; export { buildFidelityReport, fidelityStatus } from "./fidelity/fidelity-report"; -export { renderDiagramSvg, normalizeDiagram } from "./fidelity/svg/svg-diagram"; -export { renderSnapshotSvg } from "./fidelity/svg/svg-snapshot"; - +export type { BuildFidelityReportInput } from "./fidelity/fidelity-report"; export type { - FidelityStatus, FidelityBlockReport, FidelityHardRules, + FidelityStatus, PptxFidelityPolicy, } from "./fidelity/fidelity-types"; -export type { PlannerInput } from "./fidelity/representation-planner"; -export type { BuildFidelityReportInput } from "./fidelity/fidelity-report"; -export { runExportPreflight } from "./export-preflight"; -export { ExportDialog } from "./export-dialog"; \ No newline at end of file +export { runExportPreflight, compareSnapshots } from "./export-preflight"; +export { ExportDialog } from "./export-dialog"; From 4f7d3fd5411b6eead02e7a3a35ce7c2de45fc7be Mon Sep 17 00:00:00 2001 From: tph-kds <hungcompo123@gmail.com> Date: Sat, 15 Aug 2026 04:13:00 +0700 Subject: [PATCH 07/16] feat(scaffold): vendor deck chart-spec from 02-example --- .../starter-components/deck/chart-spec.ts | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 skills/deckforge/starter-components/deck/chart-spec.ts diff --git a/skills/deckforge/starter-components/deck/chart-spec.ts b/skills/deckforge/starter-components/deck/chart-spec.ts new file mode 100644 index 0000000..8bbc8b9 --- /dev/null +++ b/skills/deckforge/starter-components/deck/chart-spec.ts @@ -0,0 +1,198 @@ +import type { ChartContent, ChartValue } from "./types"; + +/** + * deck/chart-spec.ts + * + * THE single source of truth for chart semantics shared by the browser + * renderer (`src/render/Chart.tsx`) and the PPTX chart exporter + * (`src/export/pptx/block-exporters/chart.ts`). + * + * Regression (P2-001): the browser BarChart drew value-axis tick labels at the + * RIGHT edge of the plot with textAnchor="end", so the tallest bar's OUT-END + * data label (e.g. "2.4MB" for 2024) collided with the top tick label + * ("2.4MB"). The fix is enforced here, once, for both surfaces: + * + * - value-axis tick labels live on the LEFT in their own reserved column; + * - OUT-END data labels reserve vertical padding above the plot so a full + * height bar can never push a label out of the SVG bounds; + * - tick labels carry the NUMBER only (the unit renders once per data label), + * so a value is never duplicated at the same visual point. + */ + +export type ChartAxisSide = "left" | "right"; +export type DataLabelPosition = "out-end" | "in-end"; + +export interface ChartLabelPolicy { + /** Side of the plot that carries value-axis tick labels. */ + valueAxisSide: ChartAxisSide; + showDataLabels: boolean; + dataLabelPosition: DataLabelPosition; + /** Document px reserved ABOVE the plot for out-end data labels. */ + dataLabelPadding: number; + /** Document px reserved on the value-axis side for tick labels. */ + axisLabelWidth: number; + /** When false, tick labels show the number only; the unit is not repeated. */ + showUnitInAxisLabels: boolean; +} + +export interface ChartSpec { + chartType: "bar" | "bar-horizontal" | "line"; + orientation: "horizontal" | "vertical"; + title?: string; + unit: string; + values: ChartValue[]; + highlightIndex?: number; + summary?: string; + labelPolicy: ChartLabelPolicy; + dataLabelFontSizePx: number; + categoryLabelFontSizePx: number; + axisLabelFontSizePx: number; +} + +export const DEFAULT_CHART_SPEC: ChartSpec = { + chartType: "bar", + orientation: "vertical", + unit: "", + values: [], + labelPolicy: { + valueAxisSide: "left", + showDataLabels: true, + dataLabelPosition: "out-end", + dataLabelPadding: 18, + axisLabelWidth: 46, + showUnitInAxisLabels: false, + }, + dataLabelFontSizePx: 10, + categoryLabelFontSizePx: 10, + axisLabelFontSizePx: 9, +}; + +/** Derive the canonical chart spec from a block's ChartContent. */ +export function chartSpecFromContent(chart: ChartContent): ChartSpec { + return { + chartType: chart.type ?? "bar", + orientation: chart.type === "bar-horizontal" ? "horizontal" : "vertical", + title: chart.title, + unit: chart.unit ?? "", + values: Array.isArray(chart.values) ? chart.values : [], + highlightIndex: chart.highlightIndex, + summary: chart.summary, + labelPolicy: { ...DEFAULT_CHART_SPEC.labelPolicy }, + dataLabelFontSizePx: DEFAULT_CHART_SPEC.dataLabelFontSizePx, + categoryLabelFontSizePx: DEFAULT_CHART_SPEC.categoryLabelFontSizePx, + axisLabelFontSizePx: DEFAULT_CHART_SPEC.axisLabelFontSizePx, + }; +} + +export interface ChartPlotRect { + x: number; + y: number; + w: number; + h: number; +} + +export interface ChartGridline { + fraction: number; + y: number; + label: string; + labelX: number; + labelY: number; +} + +export interface BarPlacement { + barX: number; + barY: number; + barW: number; + barH: number; + dataLabelX: number; + dataLabelY: number; + categoryLabelX: number; + categoryLabelY: number; + dataLabel: string; +} + +export interface BarChartLayout { + plot: ChartPlotRect; + gridlines: ChartGridline[]; + bars: BarPlacement[]; + maxValue: number; + axisLabelAnchor: "end" | "start"; +} + +const DEFAULT_VIEWBOX = { width: 560, height: 300 }; + +/** + * Compute the deterministic bar-chart layout in viewBox document px. Both the + * browser SVG renderer and the shared label-policy tests use this so geometry + * decisions live in one module. + */ +export function describeBarChartLayout( + spec: ChartSpec, + viewBox: { width: number; height: number } = DEFAULT_VIEWBOX, +): BarChartLayout { + const { width, height } = viewBox; + const policy = spec.labelPolicy; + const titleH = spec.title ? 20 : 0; + const padX = 8; + const categoryH = 18; + const baselineH = 8; + const axisW = policy.axisLabelWidth; + const dataPad = + policy.showDataLabels && policy.dataLabelPosition === "out-end" + ? policy.dataLabelPadding + : 0; + + const axisOnLeft = policy.valueAxisSide === "left"; + const plot: ChartPlotRect = { + x: padX + (axisOnLeft ? axisW : 0), + y: titleH + dataPad, + w: width - padX * 2 - axisW, + h: height - titleH - dataPad - baselineH - categoryH, + }; + + const values = spec.values; + const maxValue = values.length + ? Math.max(...values.map((value) => value.value), 1) + : 1; + const slotW = values.length ? plot.w / values.length : plot.w; + const barW = Math.min(slotW * 0.55, 42); + + const fractions = [0, 0.25, 0.5, 0.75, 1]; + const gridlines: ChartGridline[] = fractions.map((fraction) => { + const y = plot.y + plot.h - fraction * plot.h; + const raw = Math.round(fraction * maxValue * 10) / 10; + const label = policy.showUnitInAxisLabels ? `${raw}${spec.unit}` : String(raw); + return { + fraction, + y, + label, + labelX: axisOnLeft ? plot.x - 4 : plot.x + plot.w + 4, + labelY: y - 4, + }; + }); + + const bars: BarPlacement[] = values.map((value, index) => { + const h = (value.value / maxValue) * plot.h; + const x = plot.x + index * slotW + (slotW - barW) / 2; + const y = plot.y + plot.h - h; + return { + barX: x, + barY: y, + barW, + barH: Math.max(h, 2), + dataLabelX: x + barW / 2, + dataLabelY: y - 4, + categoryLabelX: x + barW / 2, + categoryLabelY: plot.y + plot.h + baselineH + categoryH - 5, + dataLabel: `${value.value}${spec.unit}`, + }; + }); + + return { + plot, + gridlines, + bars, + maxValue, + axisLabelAnchor: axisOnLeft ? "end" : "start", + }; +} From 7a94d3b33d133a385b0f93bd82c219c128274977 Mon Sep 17 00:00:00 2001 From: tph-kds <hungcompo123@gmail.com> Date: Sat, 15 Aug 2026 04:28:55 +0700 Subject: [PATCH 08/16] chore(scaffold): sync embedded skill copy with vendored starter-components Regenerate examples/02-example/.agents/skills/deckforge to mirror the vendored scaffold deck/ and export/ modules plus the rebuilt barrel and barrel-coverage audit. Preserve two embedded-only system-prompt.md sections (slot-positioning contract, export safety) into canonical before syncing. --- .../scripts/audit_scaffold_exports.py | 50 + .../starter-components/deck-types.ts | 155 +- .../starter-components/deck/assets.ts | 215 + .../starter-components/deck/chart-spec.ts | 198 + .../starter-components/deck/commands.ts | 439 ++ .../deck/geometry-resolver.ts | 439 ++ .../deck/layout-manifest.json | 5050 +++++++++++++++++ .../starter-components/deck/layout.ts | 382 ++ .../deck/scrollbars/scrollbarTypes.ts | 37 + .../deckforge/starter-components/deck/seed.ts | 279 + .../deck/slot-validation.ts | 469 ++ .../starter-components/deck/themes.ts | 256 + .../starter-components/deck/types.ts | 319 ++ .../export/export-dialog.tsx | 499 +- .../export/export-preflight.ts | 578 +- .../starter-components/export/export-scene.ts | 156 + .../starter-components/export/export-types.ts | 116 +- .../export/fidelity/content-parity.ts | 47 +- .../export/fidelity/fidelity-report.ts | 2 +- .../export/fidelity/svg/svg-chart.ts | 233 + .../export/fidelity/svg/svg-raster.ts | 45 + .../starter-components/export/geometry.ts | 194 + .../export/image-dimensions.ts | 167 + .../starter-components/export/index.ts | 118 +- .../export/pptx/block-exporters/chart.ts | 226 +- .../export/pptx/block-exporters/diagram.ts | 60 +- .../export/pptx/block-exporters/fallback.ts | 37 +- .../export/pptx/block-exporters/image.ts | 251 +- .../export/pptx/block-exporters/index.ts | 2 +- .../export/pptx/block-exporters/process.ts | 236 + .../export/pptx/block-exporters/shape.ts | 36 +- .../export/pptx/block-exporters/table.ts | 36 +- .../export/pptx/block-exporters/text.ts | 464 +- .../export/pptx/block-exporters/video.ts | 45 +- .../export/pptx/export-utils.ts | 159 + .../export/pptx/pptx-assets.ts | 59 +- .../export/pptx/pptx-context.ts | 44 +- .../export/pptx/pptx-exporter.ts | 411 +- .../export/pptx/pptx-fallback-renderer.ts | 11 +- .../export/pptx/pptx-fonts.ts | 32 +- .../export/pptx/pptx-placeholder.ts | 17 + .../export/pptx/pptx-theme.ts | 14 +- .../export/pptx/pptx-verifier.ts | 86 +- .../export/prepare-export.ts | 181 + .../export/resolved-theme.ts | 253 + .../export/self-contained.ts | 104 + .../starter-components/export/snapshot.ts | 595 ++ skills/deckforge/system-prompt.md | 40 + 48 files changed, 12945 insertions(+), 897 deletions(-) create mode 100644 examples/02-example/.agents/skills/deckforge/scripts/audit_scaffold_exports.py create mode 100644 examples/02-example/.agents/skills/deckforge/starter-components/deck/assets.ts create mode 100644 examples/02-example/.agents/skills/deckforge/starter-components/deck/chart-spec.ts create mode 100644 examples/02-example/.agents/skills/deckforge/starter-components/deck/commands.ts create mode 100644 examples/02-example/.agents/skills/deckforge/starter-components/deck/geometry-resolver.ts create mode 100644 examples/02-example/.agents/skills/deckforge/starter-components/deck/layout-manifest.json create mode 100644 examples/02-example/.agents/skills/deckforge/starter-components/deck/layout.ts create mode 100644 examples/02-example/.agents/skills/deckforge/starter-components/deck/scrollbars/scrollbarTypes.ts create mode 100644 examples/02-example/.agents/skills/deckforge/starter-components/deck/seed.ts create mode 100644 examples/02-example/.agents/skills/deckforge/starter-components/deck/slot-validation.ts create mode 100644 examples/02-example/.agents/skills/deckforge/starter-components/deck/themes.ts create mode 100644 examples/02-example/.agents/skills/deckforge/starter-components/deck/types.ts create mode 100644 examples/02-example/.agents/skills/deckforge/starter-components/export/export-scene.ts create mode 100644 examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/svg/svg-chart.ts create mode 100644 examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/svg/svg-raster.ts create mode 100644 examples/02-example/.agents/skills/deckforge/starter-components/export/geometry.ts create mode 100644 examples/02-example/.agents/skills/deckforge/starter-components/export/image-dimensions.ts create mode 100644 examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/process.ts create mode 100644 examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/export-utils.ts create mode 100644 examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-placeholder.ts create mode 100644 examples/02-example/.agents/skills/deckforge/starter-components/export/prepare-export.ts create mode 100644 examples/02-example/.agents/skills/deckforge/starter-components/export/resolved-theme.ts create mode 100644 examples/02-example/.agents/skills/deckforge/starter-components/export/self-contained.ts create mode 100644 examples/02-example/.agents/skills/deckforge/starter-components/export/snapshot.ts diff --git a/examples/02-example/.agents/skills/deckforge/scripts/audit_scaffold_exports.py b/examples/02-example/.agents/skills/deckforge/scripts/audit_scaffold_exports.py new file mode 100644 index 0000000..72dda0a --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/scripts/audit_scaffold_exports.py @@ -0,0 +1,50 @@ +"""Audit that export/index.ts re-exports every public symbol of the scaffold export/ modules.""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +BARREL = ROOT / "skills" / "deckforge" / "starter-components" / "export" / "index.ts" +EXPORT_DIR = BARREL.parent +MODULES = ("export-types.ts", "export-preflight.ts", "export-dialog.tsx", + "snapshot.ts", "prepare-export.ts", "self-contained.ts", + "resolved-theme.ts", "geometry.ts", "image-dimensions.ts", + "export-scene.ts", "fidelity/content-parity.ts", + "fidelity/fidelity-policy.ts", "fidelity/fidelity-report.ts", + "fidelity/fidelity-types.ts", "fidelity/representation-planner.ts", + "fidelity/svg/svg-chart.ts", "fidelity/svg/svg-diagram.ts", + "fidelity/svg/svg-raster.ts", "fidelity/svg/svg-snapshot.ts", + "pptx/pptx-exporter.ts", "pptx/pptx-verifier.ts", "pptx/pptx-context.ts", + "pptx/pptx-theme.ts", "pptx/pptx-fonts.ts", "pptx/pptx-assets.ts", + "pptx/pptx-fallback-renderer.ts", "pptx/pptx-placeholder.ts", + "pptx/export-utils.ts", "pptx/block-exporters/chart.ts", + "pptx/block-exporters/diagram.ts", "pptx/block-exporters/fallback.ts", + "pptx/block-exporters/image.ts", "pptx/block-exporters/index.ts", + "pptx/block-exporters/process.ts", "pptx/block-exporters/shape.ts", + "pptx/block-exporters/table.ts", "pptx/block-exporters/text.ts", + "pptx/block-exporters/video.ts") + +SYMBOL = re.compile(r"^export\s+(?:type\s+)?(?:declare\s+)?(?:abstract\s+)?(?:async\s+)?(?:function|const|class|interface|type|enum|var|let)\s+([A-Za-z_$][\w$]*)", re.MULTILINE) + +def exported_symbols(path: Path) -> set[str]: + return {m.group(1) for m in SYMBOL.finditer(path.read_text(encoding="utf-8"))} + +def main() -> int: + barrel_text = BARREL.read_text(encoding="utf-8") + missing: list[str] = [] + for rel in MODULES: + for sym in exported_symbols(EXPORT_DIR / rel): + if not re.search(rf"\b{re.escape(sym)}\b", barrel_text): + missing.append(f"{rel}: {sym}") + if missing: + print("Barrel is missing exports for:") + for item in missing: + print(f" {item}") + return 1 + print(f"OK: barrel re-exports all symbols from {len(MODULES)} modules") + return 0 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/deck-types.ts b/examples/02-example/.agents/skills/deckforge/starter-components/deck-types.ts index 3edbdcb..65fae85 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/deck-types.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/deck-types.ts @@ -3,9 +3,6 @@ export type SlideId = string; export type BlockId = string; export type InteractionId = string; -export type Frame = { x: number; y: number; w: number; h: number; rotation?: number; z?: number }; -export type PositionMode = 'slot' | 'flow' | 'freeform' | 'background'; - export type BuildAnimation = { id: string; trigger?: 'on-enter' | 'on-click' | 'with-previous' | 'after-previous' | 'on-hover' | 'on-visible'; @@ -16,135 +13,43 @@ export type BuildAnimation = { reducedMotionFallback?: string; }; -export type DeckBlock = { - id: BlockId; - type: string; - content?: unknown; - slot?: string; - positionMode?: PositionMode; - frame?: Frame; - resolvedFrame?: Frame; - fitPolicy?: 'wrap' | 'contain' | 'cover' | 'scroll' | 'change-layout' | 'split-slide'; - style?: Record<string, unknown>; - data?: unknown; - alt?: string; - ariaLabel?: string; - sourceIds?: string[]; - animation?: BuildAnimation; - locked?: boolean; - hidden?: boolean; - decorative?: boolean; - allowOverlap?: boolean; - groupId?: string; - role?: string; -}; - -export type LayoutBinding = { slot: string; blockIds: BlockId[]; flow?: 'stack' | 'row' | 'grid' | 'overlay'; gap?: number }; +export type DeckBlock = Block; -export type Block = DeckBlock; - -export type DeckInteraction = { - id: InteractionId; - type: string; - trigger: string; - targetId?: string; - action: string; - payload?: unknown; +export type DeckInteraction = SlideInteraction & { audienceVisible?: boolean; requiresNetwork?: boolean; fallback?: string; - ariaLabel?: string; -}; - -export type DeckSlide = { - id: SlideId; - title: string; - layout: string; - layoutVariant?: string; - layoutBindings?: LayoutBinding[]; - density?: 'low' | 'medium' | 'high'; - focalBlockId?: BlockId; - blocks: DeckBlock[]; - speakerNotes?: string; - sources?: string[]; - interactions?: DeckInteraction[]; - hidden?: boolean; - section?: string; - transition?: string; - durationMs?: number; -}; - -export type DeckProject = { - schemaVersion: '2.1'; - experience: { - profile: 'editable-deck' | 'presentation-runtime' | 'published-story' | 'embedded-deck'; - surfaces: Array<'editor' | 'presenter' | 'viewer' | 'embed-viewer'>; - routes?: Record<string, string>; - capabilities?: string[]; - }; - meta: { - id: DeckId; - slug: string; - title: string; - language: string; - description?: string; - audience?: string; - objective?: string; - templateId?: string; - }; - canvas: { - aspectRatio: '16:9' | '4:3' | 'custom'; - width: number; - height: number; - safeMargin?: number; - grid?: number; - responsiveMode?: 'letterbox' | 'reflow' | 'hybrid'; - layoutMode?: 'semantic-slots' | 'hybrid' | 'freeform'; - }; - theme: { id: string; overrides?: Record<string, unknown>; designSystemRef?: string }; - presentation: { - mode: 'horizontal' | 'vertical' | 'freeform' | '3d-coverflow'; - transition: string; - keyboard: boolean; - touch?: boolean; - deepLinks?: boolean; - overview?: boolean; - speakerView?: boolean; - progress?: boolean; - controls?: boolean; - reducedMotion: 'respect-system' | 'always' | 'never'; - motionProfileId?: string; - defaultBuilds?: boolean; - }; - editor: { - enabled: boolean; - toolbar: boolean; - history: boolean; - sidePanel?: boolean; - assetLibrary?: boolean; - themePicker?: boolean; - layoutPicker?: boolean; - shortcutHelp?: boolean; - saveStatus?: boolean; - persistence?: 'none' | 'local-storage' | 'api' | 'host-managed'; - snapToGrid?: boolean; - guides?: boolean; - comments?: boolean; - collaboration?: boolean; - autosave?: boolean; - commandPalette?: boolean; - notes?: boolean; - allowedBlockTypes?: string[]; - requiredZones?: string[]; - }; - shortcuts?: { helpEnabled?: boolean; helpKey?: string; editorPreset?: string; presenterPreset?: string }; - slides: DeckSlide[]; - sources?: Array<{ id: string; title: string; url: string }>; - publish: { visibility: 'private' | 'workspace' | 'unlisted' | 'public'; embed: { enabled: boolean; allowedOrigins?: string[]; sandbox?: string[]; responsive?: boolean } }; }; export type EditorSelection = { slideId: SlideId; blockIds: BlockId[]; mode?: 'block' | 'text' | 'canvas' }; -export type SaveState = 'clean' | 'dirty' | 'saving' | 'saved' | 'failed' | 'offline' | 'conflict'; + +export type { + PositionMode, + FitPolicy, + Frame, + BlockAnimation, + BlockStyle, + ChartValue, + ChartContent, + AssetKind, + DeckAsset, + ImageBlockContent, + MetricContent, + ProcessStep, + Block, + LayoutBinding, + SlideInteraction, + DeckSlide, + SourceRef, + ThemeTokens, + ThemeGradients, + ThemeDef, + DeckProject, + SaveState, + Route, + PresenterBuildState, + RenderBlockProps, +} from './deck/types'; export type { ExportIssueSeverity, diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/deck/assets.ts b/examples/02-example/.agents/skills/deckforge/starter-components/deck/assets.ts new file mode 100644 index 0000000..6cfd4ef --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/deck/assets.ts @@ -0,0 +1,215 @@ +import type { Block, DeckAsset, DeckProject, DeckSlide, Frame, ImageBlockContent } from './types'; +import { resolveBlockFrame } from './layout'; + +/** + * Media and asset pipeline helpers (plan Workstream E). + * + * Pure, framework-free functions so they can be unit tested and reused by + * editor, presenter, and validators alike. + */ + +export type AssetStatus = 'ready' | 'failed' | 'placeholder'; + +export interface ImageIssue { + severity: 'warning' | 'error'; + code: string; + message: string; +} + +export interface ResolvedImage { + src?: string; + status: AssetStatus; + asset?: DeckAsset; +} + +/** Minimal deck shape the asset helpers depend on, for easy testing. */ +export type AssetDeck = Pick<DeckProject, 'assets'>; + +/** Read the image content of a block, tolerating both new and legacy shapes. */ +export function imageContentOf(block: Block): ImageBlockContent { + const raw = block.content; + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + return raw as ImageBlockContent; + } + return { src: typeof raw === 'string' ? raw : undefined }; +} + +/** Look up an asset manifest entry by id. */ +export function resolveAsset(deck: AssetDeck, assetId?: string): DeckAsset | undefined { + if (!assetId) return undefined; + return (deck.assets ?? []).find((asset) => asset.id === assetId); +} + +/** Canonical asset reference for an image block. */ +export interface CanonicalAssetRef { + /** Registry key: manifest asset id, or `inline:<blockId>` for inline sources. */ + assetId: string; + /** Concrete source to fetch/embed when a real source exists. */ + src?: string; + /** True when `content.assetId` points at a manifest entry that does not exist. */ + orphan?: boolean; +} + +/** + * The single, authoritative way to map an image block to the asset it needs + * embedded in an export. Manifest entries are preferred; inline `src` values + * (legacy editor state) get a deterministic synthetic keyed by block. A + * `content.assetId` that does not exist in the manifest is reported as an + * orphan ONLY when there is no inline source to fall back on — a stale id + * alongside a concrete `src` still exports that src. Returns undefined for + * placeholder blocks with no source at all. + */ +export function canonicalAssetRef(deck: AssetDeck, block: Block): CanonicalAssetRef | undefined { + const content = imageContentOf(block); + if (content.assetId) { + const asset = resolveAsset(deck, content.assetId); + if (asset) return { assetId: asset.id, src: asset.src || undefined }; + const inline = content.src ?? (block as { src?: string }).src; + if (inline) return { assetId: `inline:${block.id}`, src: inline }; + return { assetId: content.assetId, orphan: true }; + } + const inline = content.src ?? (block as { src?: string }).src; + if (inline) return { assetId: `inline:${block.id}`, src: inline }; + return undefined; +} + +/** + * Resolve the concrete source and status for an image block. + * + * - `ready`: a source exists and the asset manifest says it is valid. + * - `placeholder`: no source at all — show a designed theme-integrated placeholder. + * - `failed`: manifest marks it failed, or the block references a missing asset. + */ +export function resolveImage(deck: AssetDeck, block: Block): ResolvedImage { + const content = imageContentOf(block); + const asset = resolveAsset(deck, content.assetId); + + if (asset) { + if (asset.status === 'failed') return { src: undefined, status: 'failed', asset }; + if (asset.src) return { src: asset.src, status: 'ready', asset }; + return { src: undefined, status: 'placeholder', asset }; + } + + if (content.assetId) { + // Referenced manifest entry does not exist. + return { src: undefined, status: 'failed' }; + } + + if (content.src) return { src: content.src, status: 'ready' }; + return { src: undefined, status: 'placeholder' }; +} + +/** Clamp a focal point to the [0,1] range and default it to center. */ +export function clampFocalPoint(focal?: { x?: number; y?: number }): { x: number; y: number } { + if (!focal || typeof focal.x !== 'number' || typeof focal.y !== 'number') { + return { x: 0.5, y: 0.5 }; + } + return { + x: Math.min(1, Math.max(0, focal.x)), + y: Math.min(1, Math.max(0, focal.y)), + }; +} + +/** CSS object-position string for a focal point. */ +export function focalPointToCss(focal?: { x?: number; y?: number }): string { + const point = clampFocalPoint(focal); + return `${(point.x * 100).toFixed(1)}% ${(point.y * 100).toFixed(1)}%`; +} + +/** Aspect ratio (w/h) from an asset's intrinsic dimensions, if known. */ +export function aspectRatioOf(asset?: Pick<DeckAsset, 'width' | 'height'>): number | undefined { if (!asset || !asset.width || !asset.height) return undefined; + return asset.width / asset.height; +} + +/** Frame aspect ratio (w/h). */ +export function frameAspectRatio(frame?: Frame): number | undefined { + if (!frame || !frame.w || !frame.h) return undefined; + return frame.w / frame.h; +} + +/** + * Validate an image block against the asset contract (plan §9.3/§9.4). + * Returns issues that prevent the deck from being marked ready. + */ +export function validateImageBlock(deck: DeckProject, slide: DeckSlide, block: Block): ImageIssue[] { + const issues: ImageIssue[] = []; + const content = imageContentOf(block); + const resolved = resolveImage(deck, block); + + if (!content.decorative && !block.decorative && (!block.alt || block.alt.trim().length === 0)) { + issues.push({ + severity: 'error', + code: 'missing-alt', + message: `Image block ${block.id} has no alt text and is not marked decorative.`, + }); + } + + if (content.assetId && !resolved.asset) { + issues.push({ + severity: 'error', + code: 'unknown-asset', + message: `Image block ${block.id} references missing asset "${content.assetId}".`, + }); + } + + if (resolved.status === 'failed') { + issues.push({ + severity: 'error', + code: 'asset-failed', + message: `Image block ${block.id} references an asset marked failed.`, + }); + } + + if (resolved.asset && !resolved.asset.src) { + issues.push({ + severity: 'error', + code: 'asset-remote-only', + message: `Asset "${content.assetId}" has no local source.`, + }); + } + + if (resolved.asset && !resolved.asset.width && !resolved.asset.height) { + issues.push({ + severity: 'warning', + code: 'unknown-dimensions', + message: `Asset "${content.assetId}" has unknown intrinsic dimensions.`, + }); + } + + if (content.caption && content.caption.trim().length && content.fit !== 'contain') { + issues.push({ + severity: 'warning', + code: 'caption-crop', + message: `Image block ${block.id} has a caption; consider "contain" fit to avoid cropping the subject.`, + }); + } + + const assetRatio = aspectRatioOf(resolved.asset); + const frame = resolveBlockFrame(slide, deck.canvas, block.id); + const frameRatio = frameAspectRatio(frame); + if (assetRatio && frameRatio) { + const mismatch = Math.abs(assetRatio - frameRatio) / Math.max(frameRatio, 1e-6); + if (mismatch > 0.5) { + issues.push({ + severity: 'warning', + code: 'aspect-mismatch', + message: `Image block ${block.id} aspect ratio ${assetRatio.toFixed(2)} vs slot ${frameRatio.toFixed(2)}; a "cover" crop will cut a large area.`, + }); + } + } + + return issues; +} + +/** Aggregate image validation issues across a deck. */ +export function validateDeckAssets(deck: DeckProject): ImageIssue[] { + const issues: ImageIssue[] = []; + for (const slide of deck.slides) { + for (const block of slide.blocks) { + if (block.type === 'image') { + issues.push(...validateImageBlock(deck, slide, block)); + } + } + } + return issues; +} diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/deck/chart-spec.ts b/examples/02-example/.agents/skills/deckforge/starter-components/deck/chart-spec.ts new file mode 100644 index 0000000..8bbc8b9 --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/deck/chart-spec.ts @@ -0,0 +1,198 @@ +import type { ChartContent, ChartValue } from "./types"; + +/** + * deck/chart-spec.ts + * + * THE single source of truth for chart semantics shared by the browser + * renderer (`src/render/Chart.tsx`) and the PPTX chart exporter + * (`src/export/pptx/block-exporters/chart.ts`). + * + * Regression (P2-001): the browser BarChart drew value-axis tick labels at the + * RIGHT edge of the plot with textAnchor="end", so the tallest bar's OUT-END + * data label (e.g. "2.4MB" for 2024) collided with the top tick label + * ("2.4MB"). The fix is enforced here, once, for both surfaces: + * + * - value-axis tick labels live on the LEFT in their own reserved column; + * - OUT-END data labels reserve vertical padding above the plot so a full + * height bar can never push a label out of the SVG bounds; + * - tick labels carry the NUMBER only (the unit renders once per data label), + * so a value is never duplicated at the same visual point. + */ + +export type ChartAxisSide = "left" | "right"; +export type DataLabelPosition = "out-end" | "in-end"; + +export interface ChartLabelPolicy { + /** Side of the plot that carries value-axis tick labels. */ + valueAxisSide: ChartAxisSide; + showDataLabels: boolean; + dataLabelPosition: DataLabelPosition; + /** Document px reserved ABOVE the plot for out-end data labels. */ + dataLabelPadding: number; + /** Document px reserved on the value-axis side for tick labels. */ + axisLabelWidth: number; + /** When false, tick labels show the number only; the unit is not repeated. */ + showUnitInAxisLabels: boolean; +} + +export interface ChartSpec { + chartType: "bar" | "bar-horizontal" | "line"; + orientation: "horizontal" | "vertical"; + title?: string; + unit: string; + values: ChartValue[]; + highlightIndex?: number; + summary?: string; + labelPolicy: ChartLabelPolicy; + dataLabelFontSizePx: number; + categoryLabelFontSizePx: number; + axisLabelFontSizePx: number; +} + +export const DEFAULT_CHART_SPEC: ChartSpec = { + chartType: "bar", + orientation: "vertical", + unit: "", + values: [], + labelPolicy: { + valueAxisSide: "left", + showDataLabels: true, + dataLabelPosition: "out-end", + dataLabelPadding: 18, + axisLabelWidth: 46, + showUnitInAxisLabels: false, + }, + dataLabelFontSizePx: 10, + categoryLabelFontSizePx: 10, + axisLabelFontSizePx: 9, +}; + +/** Derive the canonical chart spec from a block's ChartContent. */ +export function chartSpecFromContent(chart: ChartContent): ChartSpec { + return { + chartType: chart.type ?? "bar", + orientation: chart.type === "bar-horizontal" ? "horizontal" : "vertical", + title: chart.title, + unit: chart.unit ?? "", + values: Array.isArray(chart.values) ? chart.values : [], + highlightIndex: chart.highlightIndex, + summary: chart.summary, + labelPolicy: { ...DEFAULT_CHART_SPEC.labelPolicy }, + dataLabelFontSizePx: DEFAULT_CHART_SPEC.dataLabelFontSizePx, + categoryLabelFontSizePx: DEFAULT_CHART_SPEC.categoryLabelFontSizePx, + axisLabelFontSizePx: DEFAULT_CHART_SPEC.axisLabelFontSizePx, + }; +} + +export interface ChartPlotRect { + x: number; + y: number; + w: number; + h: number; +} + +export interface ChartGridline { + fraction: number; + y: number; + label: string; + labelX: number; + labelY: number; +} + +export interface BarPlacement { + barX: number; + barY: number; + barW: number; + barH: number; + dataLabelX: number; + dataLabelY: number; + categoryLabelX: number; + categoryLabelY: number; + dataLabel: string; +} + +export interface BarChartLayout { + plot: ChartPlotRect; + gridlines: ChartGridline[]; + bars: BarPlacement[]; + maxValue: number; + axisLabelAnchor: "end" | "start"; +} + +const DEFAULT_VIEWBOX = { width: 560, height: 300 }; + +/** + * Compute the deterministic bar-chart layout in viewBox document px. Both the + * browser SVG renderer and the shared label-policy tests use this so geometry + * decisions live in one module. + */ +export function describeBarChartLayout( + spec: ChartSpec, + viewBox: { width: number; height: number } = DEFAULT_VIEWBOX, +): BarChartLayout { + const { width, height } = viewBox; + const policy = spec.labelPolicy; + const titleH = spec.title ? 20 : 0; + const padX = 8; + const categoryH = 18; + const baselineH = 8; + const axisW = policy.axisLabelWidth; + const dataPad = + policy.showDataLabels && policy.dataLabelPosition === "out-end" + ? policy.dataLabelPadding + : 0; + + const axisOnLeft = policy.valueAxisSide === "left"; + const plot: ChartPlotRect = { + x: padX + (axisOnLeft ? axisW : 0), + y: titleH + dataPad, + w: width - padX * 2 - axisW, + h: height - titleH - dataPad - baselineH - categoryH, + }; + + const values = spec.values; + const maxValue = values.length + ? Math.max(...values.map((value) => value.value), 1) + : 1; + const slotW = values.length ? plot.w / values.length : plot.w; + const barW = Math.min(slotW * 0.55, 42); + + const fractions = [0, 0.25, 0.5, 0.75, 1]; + const gridlines: ChartGridline[] = fractions.map((fraction) => { + const y = plot.y + plot.h - fraction * plot.h; + const raw = Math.round(fraction * maxValue * 10) / 10; + const label = policy.showUnitInAxisLabels ? `${raw}${spec.unit}` : String(raw); + return { + fraction, + y, + label, + labelX: axisOnLeft ? plot.x - 4 : plot.x + plot.w + 4, + labelY: y - 4, + }; + }); + + const bars: BarPlacement[] = values.map((value, index) => { + const h = (value.value / maxValue) * plot.h; + const x = plot.x + index * slotW + (slotW - barW) / 2; + const y = plot.y + plot.h - h; + return { + barX: x, + barY: y, + barW, + barH: Math.max(h, 2), + dataLabelX: x + barW / 2, + dataLabelY: y - 4, + categoryLabelX: x + barW / 2, + categoryLabelY: plot.y + plot.h + baselineH + categoryH - 5, + dataLabel: `${value.value}${spec.unit}`, + }; + }); + + return { + plot, + gridlines, + bars, + maxValue, + axisLabelAnchor: axisOnLeft ? "end" : "start", + }; +} diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/deck/commands.ts b/examples/02-example/.agents/skills/deckforge/starter-components/deck/commands.ts new file mode 100644 index 0000000..b0c3b40 --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/deck/commands.ts @@ -0,0 +1,439 @@ +import type { Block, DeckProject, DeckSlide } from './types'; +import { imageContentOf } from './assets'; +import { migrateLayoutBindings, newId } from './seed'; + +export type Command = + | { type: 'updateBlockContent'; slideId: string; blockId: string; content: unknown } + | { type: 'updateBlockStyle'; slideId: string; blockId: string; style: Record<string, unknown> } + | { type: 'updateBlockAlt'; slideId: string; blockId: string; alt: string } + | { type: 'updateImageSource'; slideId: string; blockId: string; src: string; width?: number; height?: number } + | { type: 'updateSlideTitle'; slideId: string; title: string } + | { type: 'updateSlideNotes'; slideId: string; notes: string } + | { type: 'updateSlideLayout'; slideId: string; layout: string } + | { type: 'updateSlideTransition'; slideId: string; transition: string } + | { type: 'addBlock'; slideId: string; block: Block; slot?: string } + | { type: 'removeBlock'; slideId: string; blockId: string } + | { type: 'duplicateBlock'; slideId: string; blockId: string } + | { type: 'setTheme'; themeId: string } + | { type: 'setCanvas'; canvas: DeckProject['canvas'] } + | { type: 'setTransition'; transition: string } + | { type: 'setMotionProfile'; motionProfileId: string } + | { type: 'setReducedMotion'; reducedMotion: 'respect-system' | 'always' | 'never' } + | { type: 'addSlide'; afterIndex?: number } + | { type: 'duplicateSlide'; slideId: string } + | { type: 'removeSlide'; slideId: string } + | { type: 'moveSlide'; fromIndex: number; toIndex: number } + | { type: 'updateMeta'; title?: string; description?: string } + | { type: 'updateBlockAnimation'; slideId: string; blockId: string; animation: Block['animation'] | null } + | { type: 'replaceDeck'; deck: DeckProject }; + +/** + * Command outcome metadata (P0-007). Every mutation reports the IDs it created + * and removed plus the slides it affected, so callers can drive selection, + * repair, and AI provenance without re-deriving state. + */ +export interface DispatchResult { + deck: DeckProject; + createdIds: string[]; + removedIds: string[]; + affectedSlideIds: string[]; +} + +function result(deck: DeckProject): DispatchResult { + return { deck, createdIds: [], removedIds: [], affectedSlideIds: [] }; +} + +function mapSlide(deck: DeckProject, slideId: string, fn: (slide: DeckSlide) => DeckSlide): DeckProject { + return { + ...deck, + slides: deck.slides.map((slide) => (slide.id === slideId ? fn(slide) : slide)), + }; +} + +/** + * Best-effort MIME type for a data: URL; remote URLs are resolved at fetch + * time and reported by the preparation phase. + */ +function mimeTypeOf(src: string): string | undefined { + if (src.startsWith('data:')) { + const mime = src.slice(5).split(';')[0]; + return mime || undefined; + } + return undefined; +} + +function mapBlock( + deck: DeckProject, + slideId: string, + blockId: string, + fn: (block: Block) => Block, +): DeckProject { + return mapSlide(deck, slideId, (slide) => ({ + ...slide, + blocks: slide.blocks.map((block) => (block.id === blockId ? fn(block) : block)), + })); +} + +function addBlockToBinding( + bindings: NonNullable<DeckSlide['layoutBindings']>, + slot: string, + blockId: string, +): NonNullable<DeckSlide['layoutBindings']> { + const existing = bindings.find((binding) => binding.slot === slot); + if (existing) { + return bindings.map((binding) => + binding.slot === slot ? { ...binding, blockIds: [...binding.blockIds, blockId] } : binding, + ); + } + return [...bindings, { slot, blockIds: [blockId], flow: 'stack', gap: 8 }]; +} + +function newSlideTemplate(title: string): DeckSlide { + const kicker = newId('b'); + const heading = newId('b'); + const body = newId('b'); + return { + id: newId('s'), + title, + layout: 'two-column', + blocks: [ + { id: kicker, type: 'text', content: 'SECTION', style: { variant: 'kicker' }, sourceIds: [], slot: 'kicker', positionMode: 'slot' }, + { id: heading, type: 'heading', content: title, style: { level: 1 }, sourceIds: [], slot: 'title', positionMode: 'slot' }, + { id: body, type: 'text', content: 'Add your content here.', style: {}, sourceIds: [], slot: 'left', positionMode: 'slot' }, + ], + speakerNotes: '', + sources: [], + interactions: [], + density: 'medium', + layoutBindings: [ + { slot: 'kicker', blockIds: [kicker], flow: 'stack', gap: 8 }, + { slot: 'title', blockIds: [heading], flow: 'stack', gap: 8 }, + { slot: 'left', blockIds: [body], flow: 'stack', gap: 8 }, + ], + }; +} + +/** + * The canonical title block is the heading bound to the `title` slot (or the + * first heading block when no title binding exists). This unifies slide + * metadata with the visible heading so the inspector, canvas, and export stay + * in sync (DF-014). + */ +function titleBlockId(slide: DeckSlide): string | undefined { + const titleBinding = (slide.layoutBindings ?? []).find((binding) => binding.slot === 'title'); + const boundId = titleBinding?.blockIds[0]; + const boundBlock = boundId ? slide.blocks.find((block) => block.id === boundId) : undefined; + if (boundBlock?.type === 'heading') return boundBlock.id; + return slide.blocks.find((block) => block.type === 'heading')?.id; +} + +/** + * Apply a command and return both the new deck and its metadata. + * `applyCommand` is a thin wrapper kept for callers that only need the deck. + */ +export function applyCommandWithResult(deck: DeckProject, command: Command): DispatchResult { + switch (command.type) { + case 'updateBlockContent': { + const next = mapSlide(deck, command.slideId, (slide) => { + const blocks = slide.blocks.map((block) => + block.id === command.blockId ? { ...block, content: command.content } : block, + ); + // Keep slide title metadata in sync with the visible heading (DF-014). + if (command.blockId === titleBlockId(slide)) { + return { ...slide, title: typeof command.content === 'string' ? command.content : slide.title, blocks }; + } + return { ...slide, blocks }; + }); + return { ...result(next), affectedSlideIds: [command.slideId] }; + } + case 'updateBlockStyle': + return { + ...result(mapBlock(deck, command.slideId, command.blockId, (block) => ({ ...block, style: { ...block.style, ...command.style } }))), + affectedSlideIds: [command.slideId], + }; + case 'updateBlockAlt': + return { + ...result(mapBlock(deck, command.slideId, command.blockId, (block) => ({ ...block, alt: command.alt }))), + affectedSlideIds: [command.slideId], + }; + case 'updateImageSource': { + // Atomic image-source edit: the block's manifest binding and the asset + // manifest stay consistent in ONE command. Previously the inspector wrote + // content.src only, leaving the manifest stale so preflight and the PPTX + // exporter could disagree about whether the image resolves (P2-004). + const trimmed = command.src.trim(); + const created: string[] = []; + let nextAssets = deck.assets ?? []; + + // The upload path knows the embedded pixel dimensions; keep them on the + // manifest entry so exporters can crop cover/contain from the real + // aspect ratio instead of stretching the frame. + const hasDims = + typeof command.width === 'number' && + command.width > 0 && + typeof command.height === 'number' && + command.height > 0; + const dims = hasDims ? { width: command.width, height: command.height } : {}; + + const next = mapSlide(deck, command.slideId, (slide) => ({ + ...slide, + blocks: slide.blocks.map((block) => { + if (block.id !== command.blockId || block.type !== 'image') return block; + const content = imageContentOf(block); + const existingAssetId = content.assetId; + const existingAsset = existingAssetId + ? nextAssets.find((a) => a.id === existingAssetId) + : undefined; + + if (!trimmed) { + // Clearing the URL unbinds the block so it renders as the designed + // placeholder (export: rasterized placeholder, never an error). + return { + ...block, + content: { ...content, src: undefined, assetId: undefined }, + }; + } + + let assetId = existingAsset ? existingAsset.id : (existingAssetId ?? ''); + if (!assetId) { + assetId = newId('asset'); + created.push(assetId); + } + + if (nextAssets.some((a) => a.id === assetId)) { + nextAssets = nextAssets.map((a) => + a.id === assetId + ? { + ...a, + src: trimmed, + mimeType: a.mimeType ?? mimeTypeOf(trimmed), + // A source replaced without known dimensions (e.g. a pasted + // URL) must not keep the previous image's aspect ratio. + ...(hasDims ? dims : { width: undefined, height: undefined }), + } + : a, + ); + } else { + nextAssets = [ + ...nextAssets, + { id: assetId, kind: 'image' as const, src: trimmed, mimeType: mimeTypeOf(trimmed), ...dims }, + ]; + } + + // The manifest owns the source; the block just binds to it. + return { + ...block, + content: { ...content, src: undefined, assetId }, + }; + }), + })); + + return { + deck: { ...next, assets: nextAssets }, + createdIds: created, + removedIds: [], + affectedSlideIds: [command.slideId], + }; + } + case 'updateSlideTitle': + return { + ...result(mapSlide(deck, command.slideId, (slide) => { + const titleId = titleBlockId(slide); + const blocks = titleId + ? slide.blocks.map((block) => (block.id === titleId ? { ...block, content: command.title } : block)) + : slide.blocks; + return { ...slide, title: command.title, blocks }; + })), + affectedSlideIds: [command.slideId], + }; + case 'updateSlideNotes': + return { + ...result(mapSlide(deck, command.slideId, (slide) => ({ ...slide, speakerNotes: command.notes }))), + affectedSlideIds: [command.slideId], + }; + case 'updateSlideLayout': + return { + ...result(mapSlide(deck, command.slideId, (slide) => migrateLayoutBindings(slide, command.layout))), + affectedSlideIds: [command.slideId], + }; + case 'updateSlideTransition': + return { + ...result(mapSlide(deck, command.slideId, (slide) => ({ ...slide, transition: command.transition }))), + affectedSlideIds: [command.slideId], + }; + case 'addBlock': { + const block = command.slot ? { ...command.block, slot: command.slot } : command.block; + const next = mapSlide(deck, command.slideId, (slide) => ({ + ...slide, + blocks: [...slide.blocks, block], + layoutBindings: command.slot + ? addBlockToBinding(slide.layoutBindings ?? [], command.slot, block.id) + : slide.layoutBindings, + })); + return { + ...result(next), + createdIds: [block.id], + affectedSlideIds: [command.slideId], + }; + } + case 'removeBlock': { + const next = mapSlide(deck, command.slideId, (slide) => ({ + ...slide, + blocks: slide.blocks.filter((block) => block.id !== command.blockId), + layoutBindings: (slide.layoutBindings ?? []).map((binding) => ({ + ...binding, + blockIds: binding.blockIds.filter((id) => id !== command.blockId), + })), + focalBlockId: slide.focalBlockId === command.blockId ? undefined : slide.focalBlockId, + })); + return { + ...result(next), + removedIds: [command.blockId], + affectedSlideIds: [command.slideId], + }; + } + case 'duplicateBlock': { + let createdId = ''; + const next = mapSlide(deck, command.slideId, (slide) => { + const source = slide.blocks.find((block) => block.id === command.blockId); + if (!source) return slide; + const copy: Block = { ...structuredClone(source), id: newId('b'), slot: source.slot, positionMode: source.slot ? 'slot' : source.positionMode }; + createdId = copy.id; + return { + ...slide, + blocks: [...slide.blocks, copy], + layoutBindings: (slide.layoutBindings ?? []).map((binding) => + binding.blockIds.includes(command.blockId) + ? { ...binding, blockIds: [...binding.blockIds, copy.id] } + : binding, + ), + }; + }); + return { + ...result(next), + createdIds: createdId ? [createdId] : [], + affectedSlideIds: [command.slideId], + }; + } + case 'setTheme': + return result({ ...deck, theme: { ...deck.theme, id: command.themeId } }); + case 'setCanvas': { + const prev = deck.canvas; + const next = command.canvas; + const scaleX = next.width / prev.width; + const scaleY = next.height / prev.height; + const needsRescale = scaleX !== 1 || scaleY !== 1; + if (!needsRescale) return result({ ...deck, canvas: next }); + const rescaled: DeckProject = { + ...deck, + canvas: next, + slides: deck.slides.map((slide) => ({ + ...slide, + blocks: slide.blocks.map((block) => { + if (!block.frame) return block; + const f = block.frame; + return { + ...block, + frame: { + x: Math.round(f.x * scaleX), + y: Math.round(f.y * scaleY), + w: Math.round(f.w * scaleX), + h: Math.round(f.h * scaleY), + }, + }; + }), + })), + }; + return { ...result(rescaled), affectedSlideIds: deck.slides.map((s) => s.id) }; + } + case 'setTransition': + return result({ ...deck, presentation: { ...deck.presentation, transition: command.transition } }); + case 'setMotionProfile': + return result({ ...deck, presentation: { ...deck.presentation, motionProfileId: command.motionProfileId } }); + case 'setReducedMotion': + return result({ ...deck, presentation: { ...deck.presentation, reducedMotion: command.reducedMotion } }); + case 'addSlide': { + const afterIndex = command.afterIndex ?? deck.slides.length - 1; + const nextSlide = newSlideTemplate('Untitled slide'); + const slides = [ + ...deck.slides.slice(0, afterIndex + 1), + nextSlide, + ...deck.slides.slice(afterIndex + 1), + ]; + return { + deck: { ...deck, slides }, + createdIds: [nextSlide.id], + removedIds: [], + affectedSlideIds: [nextSlide.id], + }; + } + case 'duplicateSlide': { + const sourceIndex = deck.slides.findIndex((slide) => slide.id === command.slideId); + if (sourceIndex < 0) return result(deck); + const copy: DeckSlide = structuredClone(deck.slides[sourceIndex]); + copy.id = newId('s'); + copy.title = `${copy.title} (copy)`; + const remap = new Map<string, string>(); + for (const block of copy.blocks) { + const old = block.id; + block.id = newId('b'); + remap.set(old, block.id); + } + copy.layoutBindings = (copy.layoutBindings ?? []).map((binding) => ({ + ...binding, + blockIds: binding.blockIds.map((id) => remap.get(id) ?? id), + })); + copy.focalBlockId = copy.focalBlockId ? remap.get(copy.focalBlockId) : undefined; + const slides = [...deck.slides]; + slides.splice(sourceIndex + 1, 0, copy); + return { + deck: { ...deck, slides }, + createdIds: [copy.id, ...copy.blocks.map((block) => block.id)], + removedIds: [], + affectedSlideIds: [copy.id], + }; + } + case 'removeSlide': { + if (deck.slides.length <= 1) return result(deck); + return { + deck: { ...deck, slides: deck.slides.filter((slide) => slide.id !== command.slideId) }, + createdIds: [], + removedIds: [command.slideId], + affectedSlideIds: deck.slides + .filter((slide) => slide.id !== command.slideId) + .map((slide) => slide.id), + }; + } + case 'moveSlide': { + const slides = [...deck.slides]; + const [moved] = slides.splice(command.fromIndex, 1); + if (!moved) return result(deck); + slides.splice(command.toIndex, 0, moved); + return { deck: { ...deck, slides }, createdIds: [], removedIds: [], affectedSlideIds: [moved.id] }; + } + case 'updateMeta': + return result({ + ...deck, + meta: { ...deck.meta, ...(command.title != null ? { title: command.title } : {}), ...(command.description != null ? { description: command.description } : {}) }, + }); + case 'updateBlockAnimation': + return { + ...result(mapBlock(deck, command.slideId, command.blockId, (block) => { + if (command.animation === null) { + const { animation: _removed, ...rest } = block; + return rest as Block; + } + return { ...block, animation: command.animation }; + })), + affectedSlideIds: [command.slideId], + }; + case 'replaceDeck': + return { deck: command.deck, createdIds: [], removedIds: [], affectedSlideIds: [] }; + default: + return result(deck); + } +} + +export function applyCommand(deck: DeckProject, command: Command): DeckProject { + return applyCommandWithResult(deck, command).deck; +} diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/deck/geometry-resolver.ts b/examples/02-example/.agents/skills/deckforge/starter-components/deck/geometry-resolver.ts new file mode 100644 index 0000000..ad36d9c --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/deck/geometry-resolver.ts @@ -0,0 +1,439 @@ +import type { Block, DeckProject, DeckSlide, Frame } from "./types"; +import { + getLayoutContract, + resolveLayout, + resolveSlidePlacements, + type LayoutSlotContract, +} from "./layout"; +import { isUsableFrame, type Rect } from "../export/geometry"; +import { + validateBlockPositioning, + type SlotValidationError, + type BlockValidationResult, +} from "./slot-validation"; + +/** + * deck/geometry-resolver.ts + * + * THE canonical slide-geometry resolution pipeline (Phase 2/5/16). + * + * A SlideDocument stores slot/flow blocks WITHOUT a persisted frame; their + * geometry is a deterministic function of the layout contract + layoutBindings. + * This module resolves EVERY block on a slide to a canonical document-pixel + * frame exactly once, so the editor, presenter, preflight, and the PPTX + * exporter can never disagree about where a block is. + * + * Invariant: visible block => resolvable canonical frame. + * A visible block that cannot be resolved (unbound slot/flow block, or a + * freeform/background block with no frame) is reported in `missingFrames` and + * MUST fail closed — never silently placed at (0,0). + * + * Resolution priority for a single block (single source of truth): + * + * 1. explicit `block.frame` + * 2. positionMode "slot" + valid slot binding → the layout slot frame + * 3. deterministic layout-engine result (auto-bind the block to the best + * slot for its type) — a generated/template block is never allowed to + * exist as positionMode "slot" without a resolvable slot, so the user is + * never forced to hand-bind generated blocks + * 4. versioned legacy migration (a persisted `resolvedFrame` from an older + * hydration run) + * 5. explicit geometry error + * + * `ensureDeckSlotBindings` implements the slot-binding contract at the source: + * it returns a NEW deck whose `layoutBindings` bind every visible slot-positioned + * block to a slot that exists in the active layout contract. + * + * IMPORTANT: resolution never mutates the input deck. Callers that want to + * persist the result may use `hydrateDeckGeometry`, which returns a NEW deck + * with `block.resolvedFrame` attached. + */ + +export interface ResolvedBlockGeometry { + blockId: string; + block: Block; + /** Canonical frame in document pixels (always usable; finite, w>0, h>0). */ + frame: Rect; + slotId?: string; + role?: string; + /** Where the frame came from, for diagnostics: explicit|slot-binding|deterministic-layout|legacy-migration. */ + resolutionSource: BlockFrameSource; +} + +export type BlockFrameSource = + | "explicit" + | "slot-binding" + | "deterministic-layout" + | "legacy-migration"; + +export interface MissingGeometry { + blockId: string; + block: Block; + reason: string; + /** Classification used by preflight diagnostics (Phase 16). */ + state: GeometryDiagnosticState; + /** The slot the block declares, when present. */ + slotId?: string; + layoutId?: string; + /** Structured validation error for developer diagnostics. */ + validationError?: SlotValidationError; +} + +export type GeometryDiagnosticState = + | "MISSING_FRAME" + | "UNKNOWN_SLOT" + | "MISSING_SLOT_ID" + | "NON_FINITE_GEOMETRY" + | "INVALID_SIZE" + | "OUT_OF_BOUNDS"; + +export interface ResolvedSlideScene { + slideId: string; + blocks: ResolvedBlockGeometry[]; + frameByBlockId: Map<string, Rect>; + /** Visible blocks with no usable frame. Export/preflight MUST fail closed. */ + missingFrames: MissingGeometry[]; +} + +const NON_SLOT_MODES: ReadonlySet<string> = new Set(["freeform", "background"]); + +/** Is this block on the semantic slot/flow layer (vs freeform/background)? */ +function isSlotMode(block: Block): boolean { + return !NON_SLOT_MODES.has(block.positionMode ?? ""); +} + +function usable(candidate: Frame | undefined): Rect | undefined { + if (!candidate) return undefined; + const rect = { x: candidate.x, y: candidate.y, w: candidate.w, h: candidate.h }; + return isUsableFrame(rect) ? rect : undefined; +} + +function slotAccepts(slot: LayoutSlotContract | undefined, type: string): boolean { + return !slot?.allowedBlocks?.length || slot.allowedBlocks.includes(type); +} + +/** + * Deterministic auto-binding for a slot-positioned block that has no binding. + * Resolution preference: + * + * 1. the block's own `slot`, when it exists in the active layout and accepts + * the block type (and still has room); + * 2. the first slot (in responsive order) that accepts the block type and has + * room; + * 3. any slot with remaining capacity (never drops a block). + * + * `boundCounts` lets multiple unbound blocks share capacity deterministically. + */ +function deterministicSlotId( + block: Block, + slide: DeckSlide, + canvas: DeckProject["canvas"], + boundCounts: Map<string, number>, +): string | undefined { + const resolved = resolveLayout(slide.layout, canvas); + const slots = resolved.map((entry) => entry.slot); + const responsiveOrder = + slots.length > 0 + ? slots + : ([] as LayoutSlotContract[]); + const hasRoom = (slot: LayoutSlotContract): boolean => { + const count = boundCounts.get(slot.id) ?? 0; + return slot.maxItems == null || count < slot.maxItems; + }; + + if (block.slot) { + const slot = responsiveOrder.find((candidate) => candidate.id === block.slot); + if (slot && slotAccepts(slot, block.type) && hasRoom(slot)) return slot.id; + } + + const preferred = responsiveOrder.find( + (slot) => slotAccepts(slot, block.type) && hasRoom(slot), + ); + if (preferred) return preferred.id; + + return responsiveOrder.find(hasRoom)?.id; +} + +export interface BlockFrameResult { + frame: Rect; + slotId?: string; + role?: string; + source: BlockFrameSource; +} + +/** + * Resolve the canonical frame for a single block (single source of truth). + * Resolution priority: + * + * 1. explicit `block.frame` + * 2. a valid slot binding → the layout slot frame + * 3. a deterministic auto-binding → the layout slot frame + * 4. a persisted `resolvedFrame` (legacy migration) + * 5. nothing (caller reports the geometry error) + */ +export function resolveBlockFrame( + block: Block, + slide: DeckSlide, + canvas: DeckProject["canvas"], + placement: { slotId: string; role: string; frame: Frame } | undefined, + boundCounts?: Map<string, number>, +): BlockFrameResult | undefined { + const mode = block.positionMode ?? ""; + + if (mode === "freeform" || mode === "background") { + const explicit = usable(block.frame); + if (explicit) return { frame: explicit, source: "explicit" }; + const legacy = usable(block.resolvedFrame); + if (legacy) return { frame: legacy, source: "legacy-migration" }; + return undefined; + } + + // 1. explicit frame wins for slot/flow blocks too. + const explicit = usable(block.frame); + if (explicit) return { frame: explicit, source: "explicit" }; + + // 2. valid slot binding. + if (placement) { + const slotFrame = usable(placement.frame); + if (slotFrame) { + return { + frame: slotFrame, + slotId: placement.slotId, + role: placement.role, + source: "slot-binding", + }; + } + } + + // 3. deterministic auto-binding (slot-positioned blocks never go unbound). + const counts = boundCounts ?? new Map<string, number>(); + const slotId = deterministicSlotId(block, slide, canvas, counts); + if (slotId) { + const entry = resolveLayout(slide.layout, canvas).find((entry) => entry.slot.id === slotId); + const slotFrame = usable(entry?.frame); + if (slotFrame) { + counts.set(slotId, (counts.get(slotId) ?? 0) + 1); + return { + frame: slotFrame, + slotId, + role: entry!.slot.role, + source: "deterministic-layout", + }; + } + } + + // 4. legacy migration. + const legacy = usable(block.resolvedFrame); + if (legacy) return { frame: legacy, source: "legacy-migration" }; + + // 5. explicit geometry error (caller reports). + return undefined; +} + +function geometryStateFor( + block: Block, + slide: DeckSlide, +): GeometryDiagnosticState { + if (block.frame) { + const errors = [ + ...(!Number.isFinite(block.frame.x) ? ["x"] : []), + ...(!Number.isFinite(block.frame.y) ? ["y"] : []), + ...(!Number.isFinite(block.frame.w) ? ["w"] : []), + ...(!Number.isFinite(block.frame.h) ? ["h"] : []), + ]; + if (errors.length) return "NON_FINITE_GEOMETRY"; + if (block.frame.w <= 0 || block.frame.h <= 0) return "INVALID_SIZE"; + return "OUT_OF_BOUNDS"; + } + if (block.positionMode === "slot") { + if (!block.slot) return "MISSING_SLOT_ID"; + const layout = getLayoutContract(slide.layout); + const hasSlot = layout?.composition.slots.some((slot) => slot.id === block.slot) ?? false; + if (!hasSlot) return "UNKNOWN_SLOT"; + } + return "MISSING_FRAME"; +} + +/** + * Resolve the canonical frame for a single block given its slot placement + * (when bound) and the canvas. Returns undefined when no usable frame exists. + */ +export function resolveBlockGeometry( + block: Block, + placement: { slotId: string; role: string; frame: Frame } | undefined, + slide?: DeckSlide, + canvas?: DeckProject["canvas"], +): ResolvedBlockGeometry | undefined { + const resolved = resolveBlockFrame( + block, + slide ?? { + id: "standalone", + title: "", + layout: "two-column", + blocks: [block], + } as DeckSlide, + canvas ?? { aspectRatio: "16:9", width: 1600, height: 900, safeMargin: 64 } as DeckProject["canvas"], + placement, + ); + if (!resolved) return undefined; + return { + blockId: block.id, + block, + frame: resolved.frame, + slotId: resolved.slotId, + role: resolved.role, + resolutionSource: resolved.source, + }; +} + +/** + * Ensure the slot-binding contract: every visible slot-positioned block is + * bound to a slot that exists in the active layout. Returns a NEW slide; the + * input is never mutated. + */ +export function ensureSlideSlotBindings(slide: DeckSlide, canvas: DeckProject["canvas"]): DeckSlide { + const contractSlots = resolveLayout(slide.layout, canvas).map((entry) => entry.slot.id); + const slotSet = new Set(contractSlots); + const bindings = new Map<string, Set<string>>(); + for (const binding of slide.layoutBindings ?? []) { + if (!slotSet.has(binding.slot)) continue; + bindings.set(binding.slot, new Set(binding.blockIds)); + } + + const blockById = new Map(slide.blocks.map((block) => [block.id, block])); + const boundCounts = new Map<string, number>(); + for (const [slotId, ids] of bindings) { + boundCounts.set(slotId, ids.size); + } + + for (const block of slide.blocks) { + if (block.hidden) continue; + if (!isSlotMode(block)) continue; + const alreadyBound = [...bindings.values()].some((ids) => ids.has(block.id)); + if (alreadyBound) continue; + const slotId = deterministicSlotId(block, slide, canvas, boundCounts); + if (!slotId) continue; + if (!bindings.has(slotId)) bindings.set(slotId, new Set()); + bindings.get(slotId)!.add(block.id); + boundCounts.set(slotId, (boundCounts.get(slotId) ?? 0) + 1); + } + + const blockSeen = new Set<string>(); + const ordered = contractSlots + .filter((slotId) => bindings.has(slotId)) + .map((slotId) => { + const ids = [...bindings.get(slotId)!].filter((id) => blockById.has(id)); + ids.forEach((id) => blockSeen.add(id)); + return { slot: slotId, blockIds: ids }; + }); + + return { ...slide, layoutBindings: ordered }; +} + +/** + * Ensure the slot-binding contract across the whole deck. Returns a NEW deck + * whose `layoutBindings` bind every visible slot-positioned block to a valid + * slot, so generated/template decks never ship an unbound slot block. + */ +export function ensureDeckSlotBindings(deck: DeckProject): DeckProject { + const canvas = deck.canvas ?? { aspectRatio: "16:9", width: 1600, height: 900, safeMargin: 64 }; + return { + ...deck, + slides: deck.slides.map((slide) => ensureSlideSlotBindings(slide, canvas)), + }; +} + +/** Resolve every block on a slide to its canonical frame. */ +export function resolveSlideGeometry( + slide: DeckSlide, + canvas: DeckProject["canvas"], +): ResolvedSlideScene { + const placements = resolveSlidePlacements(slide, canvas); + const placementByBlock = new Map< + string, + { slotId: string; role: string; frame: Frame } + >(); + for (const placement of placements) { + placementByBlock.set(placement.blockId, { + slotId: placement.slotId, + role: placement.slot.role, + frame: placement.frame, + }); + } + + const blocks: ResolvedBlockGeometry[] = []; + const frameByBlockId = new Map<string, Rect>(); + const missingFrames: MissingGeometry[] = []; + const boundCounts = new Map<string, number>(); + for (const placement of placements) { + boundCounts.set(placement.slotId, (boundCounts.get(placement.slotId) ?? 0) + 1); + } + + for (const block of slide.blocks) { + if (block.hidden) continue; + const placement = placementByBlock.get(block.id); + const resolved = resolveBlockFrame(block, slide, canvas, placement, boundCounts); + if (resolved) { + blocks.push({ + blockId: block.id, + block, + frame: resolved.frame, + slotId: resolved.slotId, + role: resolved.role, + resolutionSource: resolved.source, + }); + frameByBlockId.set(block.id, resolved.frame); + } else { + // Get structured validation error for developer diagnostics + const validationResult = validateBlockPositioning(block, slide, canvas); + const validationError = validationResult.errors.length > 0 ? validationResult.errors[0] : undefined; + + missingFrames.push({ + blockId: block.id, + block, + slotId: block.slot, + layoutId: slide.layout, + state: geometryStateFor(block, slide), + reason: `${block.type} block "${block.id}" (positionMode "${block.positionMode ?? "slot"}") has no resolvable frame`, + validationError, + }); + } + } + + return { slideId: slide.id, blocks, frameByBlockId, missingFrames }; +} + +/** Resolve all slides in a deck. */ +export function resolveDeckScenes(deck: DeckProject): Map<string, ResolvedSlideScene> { + const scenes = new Map<string, ResolvedSlideScene>(); + for (const slide of deck.slides) { + scenes.set(slide.id, resolveSlideGeometry(slide, deck.canvas)); + } + return scenes; +} + +/** + * Return a NEW deck with `block.resolvedFrame` attached to every resolvable + * block. Safe for document load / migration / post-mutation hydration: the + * original deck is never mutated, so editor history and export both stay + * deterministic and idempotent. + */ +export function hydrateDeckGeometry(deck: DeckProject): DeckProject { + const scenes = resolveDeckScenes(deck); + const hydrated: DeckProject = { + ...deck, + slides: deck.slides.map((slide) => { + const scene = scenes.get(slide.id); + if (!scene) return slide; + return { + ...slide, + blocks: slide.blocks.map((block) => { + const frame = scene.frameByBlockId.get(block.id); + if (!frame) return block; + return { ...block, resolvedFrame: { ...frame } }; + }), + }; + }), + }; + return hydrated; +} diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/deck/layout-manifest.json b/examples/02-example/.agents/skills/deckforge/starter-components/deck/layout-manifest.json new file mode 100644 index 0000000..fc743fb --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/deck/layout-manifest.json @@ -0,0 +1,5050 @@ +[ + { + "id": "title-hero", + "name": "Title Hero", + "category": "Opening", + "purpose": "One decisive title, short subtitle, one visual anchor", + "density": "low", + "recommendedBlocks": [ + "heading", + "text", + "image" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 7, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 75, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 7, + "rowSpan": 3 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 78, + "maxLines": 3 + } + }, + { + "id": "subtitle", + "role": "support", + "grid": { + "column": 1, + "row": 5, + "columnSpan": 7, + "rowSpan": 2 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 2, + "priority": "normal", + "contentBudget": { + "maxCharacters": 220, + "maxLines": 4 + } + }, + { + "id": "meta", + "role": "footer", + "grid": { + "column": 1, + "row": 7, + "columnSpan": 7, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text", + "caption" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 120, + "maxLines": 2 + } + }, + { + "id": "visual", + "role": "visual", + "grid": { + "column": 9, + "row": 2, + "columnSpan": 4, + "rowSpan": 5 + }, + "allowedBlocks": [ + "image", + "video", + "gallery", + "browser-demo", + "device", + "embed", + "diagram", + "chart", + "map", + "code", + "terminal-demo", + "icon" + ], + "required": false, + "maxItems": 1, + "priority": "secondary" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "subtitle", + "visual", + "meta" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.34, + "maxOccupiedRatio": 0.74 + }, + "freeformAllowed": false, + "notes": "Title and visual occupy disjoint regions. Never allow the title to extend into columns 9–12." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "section-divider", + "name": "Section Divider", + "category": "Opening", + "purpose": "Section title with chapter number and quiet context", + "density": "low", + "recommendedBlocks": [ + "heading", + "text" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "chapter", + "role": "context", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 3, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text", + "metric" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 20, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 10, + "rowSpan": 2 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 80, + "maxLines": 2 + } + }, + { + "id": "context", + "role": "support", + "grid": { + "column": 1, + "row": 5, + "columnSpan": 8, + "rowSpan": 2 + }, + "allowedBlocks": [ + "text", + "quote" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 180, + "maxLines": 4 + } + }, + { + "id": "accent", + "role": "visual", + "grid": { + "column": 10, + "row": 2, + "columnSpan": 3, + "rowSpan": 4 + }, + "allowedBlocks": [ + "image", + "video", + "gallery", + "browser-demo", + "device", + "embed", + "diagram", + "chart", + "map", + "code", + "terminal-demo", + "icon" + ], + "required": false, + "maxItems": 1, + "priority": "normal" + } + ], + "responsiveOrder": [ + "chapter", + "title", + "context", + "accent" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.25, + "maxOccupiedRatio": 0.64 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "statement", + "name": "Statement", + "category": "Narrative", + "purpose": "Single argument or conclusion with supporting phrase", + "density": "low", + "recommendedBlocks": [ + "heading", + "text" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "statement", + "role": "title", + "grid": { + "column": 2, + "row": 2, + "columnSpan": 10, + "rowSpan": 4 + }, + "allowedBlocks": [ + "heading", + "quote" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 120, + "maxLines": 4 + } + }, + { + "id": "support", + "role": "support", + "grid": { + "column": 3, + "row": 6, + "columnSpan": 8, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text", + "citation" + ], + "required": false, + "maxItems": 2, + "priority": "normal", + "contentBudget": { + "maxCharacters": 180, + "maxLines": 2 + } + } + ], + "responsiveOrder": [ + "statement", + "support" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.2, + "maxOccupiedRatio": 0.58 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "centered-quote", + "name": "Centered Quote", + "category": "Narrative", + "purpose": "Short quote with source and restrained treatment", + "density": "low", + "recommendedBlocks": [ + "quote", + "citation" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "quote", + "role": "title", + "grid": { + "column": 2, + "row": 2, + "columnSpan": 10, + "rowSpan": 4 + }, + "allowedBlocks": [ + "quote", + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 220, + "maxLines": 6 + } + }, + { + "id": "source", + "role": "source", + "grid": { + "column": 4, + "row": 6, + "columnSpan": 6, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text", + "citation", + "caption" + ], + "required": true, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 120, + "maxLines": 2 + } + } + ], + "responsiveOrder": [ + "quote", + "source" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.22, + "maxOccupiedRatio": 0.6 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "big-number", + "name": "Big Number", + "category": "Data", + "purpose": "One dominant metric with meaning and comparison", + "density": "low", + "recommendedBlocks": [ + "metric", + "text" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "context", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "metric", + "role": "primary", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 7, + "rowSpan": 4 + }, + "allowedBlocks": [ + "metric" + ], + "required": true, + "maxItems": 1, + "priority": "primary" + }, + { + "id": "meaning", + "role": "support", + "grid": { + "column": 8, + "row": 3, + "columnSpan": 5, + "rowSpan": 3 + }, + "allowedBlocks": [ + "heading", + "text", + "callout", + "chart" + ], + "required": false, + "maxItems": 3, + "priority": "normal", + "contentBudget": { + "maxCharacters": 220, + "maxLines": 6 + } + }, + { + "id": "footer", + "role": "footer", + "grid": { + "column": 1, + "row": 8, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text", + "caption", + "citation" + ], + "required": false, + "maxItems": 3, + "priority": "normal", + "contentBudget": { + "maxCharacters": 180, + "maxLines": 2 + } + } + ], + "responsiveOrder": [ + "context", + "metric", + "meaning", + "footer" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.35, + "maxOccupiedRatio": 0.72 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "metric-grid", + "name": "Metric Grid", + "category": "Data", + "purpose": "Three to six metrics with consistent units and hierarchy", + "density": "medium", + "recommendedBlocks": [ + "metric", + "card-grid" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "metrics", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 12, + "rowSpan": 4 + }, + "allowedBlocks": [ + "metric" + ], + "required": true, + "maxItems": 6, + "priority": "primary" + }, + { + "id": "takeaway", + "role": "support", + "grid": { + "column": 1, + "row": 7, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text", + "callout" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 160, + "maxLines": 2 + } + } + ], + "responsiveOrder": [ + "kicker", + "title", + "metrics", + "takeaway" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.44, + "maxOccupiedRatio": 0.8 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "two-column", + "name": "Two Column", + "category": "General", + "purpose": "Balanced text and visual or two related ideas", + "density": "medium", + "recommendedBlocks": [ + "heading", + "text", + "image" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "left", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 6, + "rowSpan": 5 + }, + "allowedBlocks": [ + "heading", + "text", + "bullets", + "callout", + "caption", + "citation", + "quote", + "image", + "video", + "gallery", + "browser-demo", + "device", + "embed", + "diagram", + "chart", + "map", + "code", + "terminal-demo", + "metric", + "table", + "comparison", + "timeline", + "process", + "icon", + "logo-wall", + "people-grid", + "audio" + ], + "required": true, + "maxItems": 5, + "priority": "primary" + }, + { + "id": "right", + "role": "secondary", + "grid": { + "column": 7, + "row": 3, + "columnSpan": 6, + "rowSpan": 5 + }, + "allowedBlocks": [ + "heading", + "text", + "bullets", + "callout", + "caption", + "citation", + "quote", + "image", + "video", + "gallery", + "browser-demo", + "device", + "embed", + "diagram", + "chart", + "map", + "code", + "terminal-demo", + "metric", + "table", + "comparison", + "timeline", + "process", + "icon", + "logo-wall", + "people-grid", + "audio" + ], + "required": true, + "maxItems": 4, + "priority": "secondary" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "left", + "right" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.46, + "maxOccupiedRatio": 0.84 + }, + "freeformAllowed": false, + "notes": "Use independent flow containers. Never let left/right blocks cross the column boundary." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "split-visual", + "name": "Split Visual", + "category": "General", + "purpose": "Large visual paired with concise interpretation", + "density": "medium", + "recommendedBlocks": [ + "image", + "text", + "caption" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "visual", + "role": "visual", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 7, + "rowSpan": 5 + }, + "allowedBlocks": [ + "image", + "video", + "gallery", + "browser-demo", + "device", + "embed", + "diagram", + "chart", + "map", + "code", + "terminal-demo" + ], + "required": true, + "maxItems": 1, + "priority": "primary" + }, + { + "id": "interpretation", + "role": "support", + "grid": { + "column": 8, + "row": 3, + "columnSpan": 5, + "rowSpan": 5 + }, + "allowedBlocks": [ + "heading", + "text", + "bullets", + "callout", + "caption", + "citation", + "quote", + "metric" + ], + "required": true, + "maxItems": 5, + "priority": "secondary" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "visual", + "interpretation" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.5, + "maxOccupiedRatio": 0.86 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "three-column", + "name": "Three Column", + "category": "General", + "purpose": "Three parallel concepts with equal weight", + "density": "medium", + "recommendedBlocks": [ + "card-grid", + "icon", + "text" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "column-1", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 4, + "rowSpan": 5 + }, + "allowedBlocks": [ + "heading", + "text", + "bullets", + "callout", + "caption", + "citation", + "quote", + "image", + "video", + "gallery", + "browser-demo", + "device", + "embed", + "diagram", + "chart", + "map", + "code", + "terminal-demo", + "metric", + "table", + "comparison", + "timeline", + "process", + "icon", + "logo-wall", + "people-grid", + "audio" + ], + "required": true, + "maxItems": 4, + "priority": "normal" + }, + { + "id": "column-2", + "role": "primary", + "grid": { + "column": 5, + "row": 3, + "columnSpan": 4, + "rowSpan": 5 + }, + "allowedBlocks": [ + "heading", + "text", + "bullets", + "callout", + "caption", + "citation", + "quote", + "image", + "video", + "gallery", + "browser-demo", + "device", + "embed", + "diagram", + "chart", + "map", + "code", + "terminal-demo", + "metric", + "table", + "comparison", + "timeline", + "process", + "icon", + "logo-wall", + "people-grid", + "audio" + ], + "required": true, + "maxItems": 4, + "priority": "normal" + }, + { + "id": "column-3", + "role": "primary", + "grid": { + "column": 9, + "row": 3, + "columnSpan": 4, + "rowSpan": 5 + }, + "allowedBlocks": [ + "heading", + "text", + "bullets", + "callout", + "caption", + "citation", + "quote", + "image", + "video", + "gallery", + "browser-demo", + "device", + "embed", + "diagram", + "chart", + "map", + "code", + "terminal-demo", + "metric", + "table", + "comparison", + "timeline", + "process", + "icon", + "logo-wall", + "people-grid", + "audio" + ], + "required": true, + "maxItems": 4, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "column-1", + "column-2", + "column-3" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.48, + "maxOccupiedRatio": 0.86 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "card-grid", + "name": "Card Grid", + "category": "General", + "purpose": "Modular points where cards are semantically justified", + "density": "medium", + "recommendedBlocks": [ + "card-grid" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "cards", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 12, + "rowSpan": 5 + }, + "allowedBlocks": [ + "callout", + "metric", + "image", + "text", + "people-grid", + "logo-wall" + ], + "required": true, + "maxItems": 8, + "priority": "primary" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "cards" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.48, + "maxOccupiedRatio": 0.88 + }, + "freeformAllowed": false, + "notes": "Cards must be semantically justified and use a consistent internal hierarchy." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "timeline-horizontal", + "name": "Timeline Horizontal", + "category": "Process", + "purpose": "Time-based milestones across a horizontal axis", + "density": "medium", + "recommendedBlocks": [ + "timeline" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "timeline", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 12, + "rowSpan": 4 + }, + "allowedBlocks": [ + "timeline", + "process", + "diagram" + ], + "required": true, + "maxItems": 1, + "priority": "primary" + }, + { + "id": "takeaway", + "role": "support", + "grid": { + "column": 1, + "row": 7, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text", + "callout" + ], + "required": false, + "maxItems": 2, + "priority": "normal", + "contentBudget": { + "maxCharacters": 180, + "maxLines": 2 + } + } + ], + "responsiveOrder": [ + "kicker", + "title", + "timeline", + "takeaway" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.42, + "maxOccupiedRatio": 0.82 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "timeline-vertical", + "name": "Timeline Vertical", + "category": "Process", + "purpose": "Chronological narrative with more explanatory text", + "density": "medium", + "recommendedBlocks": [ + "timeline" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "timeline", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 7, + "rowSpan": 5 + }, + "allowedBlocks": [ + "timeline", + "process" + ], + "required": true, + "maxItems": 1, + "priority": "primary" + }, + { + "id": "details", + "role": "support", + "grid": { + "column": 8, + "row": 3, + "columnSpan": 5, + "rowSpan": 5 + }, + "allowedBlocks": [ + "heading", + "text", + "bullets", + "callout", + "caption", + "citation", + "quote", + "metric" + ], + "required": false, + "maxItems": 5, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "timeline", + "details" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.48, + "maxOccupiedRatio": 0.84 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "process-steps", + "name": "Process Steps", + "category": "Process", + "purpose": "Three to seven ordered actions with clear progression", + "density": "medium", + "recommendedBlocks": [ + "process" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "steps", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 12, + "rowSpan": 4 + }, + "allowedBlocks": [ + "process", + "timeline", + "diagram" + ], + "required": true, + "maxItems": 1, + "priority": "primary" + }, + { + "id": "outcome", + "role": "support", + "grid": { + "column": 2, + "row": 7, + "columnSpan": 10, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text", + "callout" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 180, + "maxLines": 2 + } + } + ], + "responsiveOrder": [ + "kicker", + "title", + "steps", + "outcome" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.42, + "maxOccupiedRatio": 0.82 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "funnel", + "name": "Funnel", + "category": "Process", + "purpose": "Narrowing stages with volume or qualification changes", + "density": "medium", + "recommendedBlocks": [ + "diagram" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "funnel", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 7, + "rowSpan": 5 + }, + "allowedBlocks": [ + "diagram", + "chart", + "process" + ], + "required": true, + "maxItems": 1, + "priority": "normal" + }, + { + "id": "metrics", + "role": "support", + "grid": { + "column": 8, + "row": 3, + "columnSpan": 5, + "rowSpan": 5 + }, + "allowedBlocks": [ + "metric", + "text", + "callout" + ], + "required": false, + "maxItems": 5, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "funnel", + "metrics" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.45, + "maxOccupiedRatio": 0.82 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "pyramid", + "name": "Pyramid", + "category": "Strategy", + "purpose": "Layered priorities, maturity, or hierarchy", + "density": "medium", + "recommendedBlocks": [ + "diagram" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "pyramid", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 7, + "rowSpan": 5 + }, + "allowedBlocks": [ + "diagram", + "chart" + ], + "required": true, + "maxItems": 1, + "priority": "normal" + }, + { + "id": "explanation", + "role": "support", + "grid": { + "column": 8, + "row": 3, + "columnSpan": 5, + "rowSpan": 5 + }, + "allowedBlocks": [ + "heading", + "text", + "bullets", + "callout", + "caption", + "citation", + "quote", + "metric" + ], + "required": false, + "maxItems": 5, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "pyramid", + "explanation" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.45, + "maxOccupiedRatio": 0.82 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "matrix-2x2", + "name": "Matrix 2X2", + "category": "Strategy", + "purpose": "Four quadrants with meaningful axes and labels", + "density": "medium", + "recommendedBlocks": [ + "diagram" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "matrix", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 8, + "rowSpan": 5 + }, + "allowedBlocks": [ + "diagram", + "chart", + "comparison" + ], + "required": true, + "maxItems": 1, + "priority": "normal" + }, + { + "id": "insight", + "role": "support", + "grid": { + "column": 9, + "row": 3, + "columnSpan": 4, + "rowSpan": 5 + }, + "allowedBlocks": [ + "text", + "callout", + "metric" + ], + "required": false, + "maxItems": 4, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "matrix", + "insight" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.48, + "maxOccupiedRatio": 0.84 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "comparison", + "name": "Comparison", + "category": "Decision", + "purpose": "Side-by-side alternatives with explicit criteria", + "density": "high", + "recommendedBlocks": [ + "comparison", + "table" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "option-a", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 5, + "rowSpan": 4 + }, + "allowedBlocks": [ + "heading", + "text", + "bullets", + "callout", + "caption", + "citation", + "quote", + "image", + "video", + "gallery", + "browser-demo", + "device", + "embed", + "diagram", + "chart", + "map", + "code", + "terminal-demo", + "metric", + "table", + "comparison", + "timeline", + "process", + "icon", + "logo-wall", + "people-grid", + "audio" + ], + "required": true, + "maxItems": 5, + "priority": "normal" + }, + { + "id": "criteria", + "role": "context", + "grid": { + "column": 6, + "row": 3, + "columnSpan": 2, + "rowSpan": 4 + }, + "allowedBlocks": [ + "text", + "table", + "comparison" + ], + "required": false, + "maxItems": 6, + "priority": "normal" + }, + { + "id": "option-b", + "role": "primary", + "grid": { + "column": 8, + "row": 3, + "columnSpan": 5, + "rowSpan": 4 + }, + "allowedBlocks": [ + "heading", + "text", + "bullets", + "callout", + "caption", + "citation", + "quote", + "image", + "video", + "gallery", + "browser-demo", + "device", + "embed", + "diagram", + "chart", + "map", + "code", + "terminal-demo", + "metric", + "table", + "comparison", + "timeline", + "process", + "icon", + "logo-wall", + "people-grid", + "audio" + ], + "required": true, + "maxItems": 5, + "priority": "normal" + }, + { + "id": "decision", + "role": "support", + "grid": { + "column": 1, + "row": 7, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "callout", + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 180, + "maxLines": 2 + } + } + ], + "responsiveOrder": [ + "kicker", + "title", + "option-a", + "criteria", + "option-b", + "decision" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.5, + "maxOccupiedRatio": 0.88 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "before-after", + "name": "Before After", + "category": "Decision", + "purpose": "Current state versus target state", + "density": "medium", + "recommendedBlocks": [ + "comparison", + "image" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "before", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 5, + "rowSpan": 4 + }, + "allowedBlocks": [ + "heading", + "text", + "bullets", + "callout", + "caption", + "citation", + "quote", + "image", + "video", + "gallery", + "browser-demo", + "device", + "embed", + "diagram", + "chart", + "map", + "code", + "terminal-demo", + "metric", + "table", + "comparison", + "timeline", + "process", + "icon", + "logo-wall", + "people-grid", + "audio" + ], + "required": true, + "maxItems": 5, + "priority": "normal" + }, + { + "id": "transition", + "role": "context", + "grid": { + "column": 6, + "row": 4, + "columnSpan": 2, + "rowSpan": 2 + }, + "allowedBlocks": [ + "icon", + "text" + ], + "required": false, + "maxItems": 2, + "priority": "normal" + }, + { + "id": "after", + "role": "primary", + "grid": { + "column": 8, + "row": 3, + "columnSpan": 5, + "rowSpan": 4 + }, + "allowedBlocks": [ + "heading", + "text", + "bullets", + "callout", + "caption", + "citation", + "quote", + "image", + "video", + "gallery", + "browser-demo", + "device", + "embed", + "diagram", + "chart", + "map", + "code", + "terminal-demo", + "metric", + "table", + "comparison", + "timeline", + "process", + "icon", + "logo-wall", + "people-grid", + "audio" + ], + "required": true, + "maxItems": 5, + "priority": "normal" + }, + { + "id": "impact", + "role": "support", + "grid": { + "column": 1, + "row": 7, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "metric", + "callout", + "text" + ], + "required": false, + "maxItems": 3, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "before", + "transition", + "after", + "impact" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.48, + "maxOccupiedRatio": 0.86 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "architecture", + "name": "Architecture", + "category": "Technical", + "purpose": "System components, boundaries, and data flows", + "density": "high", + "recommendedBlocks": [ + "diagram", + "code" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "diagram", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 9, + "rowSpan": 5 + }, + "allowedBlocks": [ + "diagram" + ], + "required": true, + "maxItems": 1, + "priority": "primary" + }, + { + "id": "legend", + "role": "support", + "grid": { + "column": 10, + "row": 3, + "columnSpan": 3, + "rowSpan": 3 + }, + "allowedBlocks": [ + "text", + "caption", + "callout" + ], + "required": false, + "maxItems": 5, + "priority": "normal" + }, + { + "id": "takeaway", + "role": "support", + "grid": { + "column": 10, + "row": 6, + "columnSpan": 3, + "rowSpan": 2 + }, + "allowedBlocks": [ + "text", + "callout" + ], + "required": false, + "maxItems": 2, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "diagram", + "legend", + "takeaway" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.5, + "maxOccupiedRatio": 0.88 + }, + "freeformAllowed": false, + "notes": "All diagram nodes and edge labels must remain inside the diagram slot." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "flowchart", + "name": "Flowchart", + "category": "Technical", + "purpose": "Decision or operational flow with directional logic", + "density": "high", + "recommendedBlocks": [ + "diagram" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "flow", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 9, + "rowSpan": 5 + }, + "allowedBlocks": [ + "diagram", + "process" + ], + "required": true, + "maxItems": 1, + "priority": "normal" + }, + { + "id": "notes", + "role": "support", + "grid": { + "column": 10, + "row": 3, + "columnSpan": 3, + "rowSpan": 5 + }, + "allowedBlocks": [ + "text", + "callout", + "caption" + ], + "required": false, + "maxItems": 5, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "flow", + "notes" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.48, + "maxOccupiedRatio": 0.86 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "radial", + "name": "Radial", + "category": "Technical", + "purpose": "Hub-and-spoke ecosystem or capability map", + "density": "medium", + "recommendedBlocks": [ + "diagram" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "map", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 8, + "rowSpan": 5 + }, + "allowedBlocks": [ + "diagram", + "chart" + ], + "required": true, + "maxItems": 1, + "priority": "normal" + }, + { + "id": "legend", + "role": "support", + "grid": { + "column": 9, + "row": 3, + "columnSpan": 4, + "rowSpan": 5 + }, + "allowedBlocks": [ + "text", + "callout", + "metric" + ], + "required": false, + "maxItems": 6, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "map", + "legend" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.46, + "maxOccupiedRatio": 0.84 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "map", + "name": "Map", + "category": "Geographic", + "purpose": "Locations or regional metrics with legend", + "density": "high", + "recommendedBlocks": [ + "map" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "map", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 8, + "rowSpan": 5 + }, + "allowedBlocks": [ + "map", + "image", + "chart" + ], + "required": true, + "maxItems": 1, + "priority": "normal" + }, + { + "id": "legend", + "role": "support", + "grid": { + "column": 9, + "row": 3, + "columnSpan": 4, + "rowSpan": 3 + }, + "allowedBlocks": [ + "text", + "caption", + "metric" + ], + "required": false, + "maxItems": 6, + "priority": "normal" + }, + { + "id": "takeaway", + "role": "support", + "grid": { + "column": 9, + "row": 6, + "columnSpan": 4, + "rowSpan": 2 + }, + "allowedBlocks": [ + "callout", + "text" + ], + "required": false, + "maxItems": 2, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "map", + "legend", + "takeaway" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.5, + "maxOccupiedRatio": 0.86 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "dashboard", + "name": "Dashboard", + "category": "Data", + "purpose": "Multiple coordinated views with a clear focal KPI", + "density": "high", + "recommendedBlocks": [ + "metric", + "chart", + "table" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "hero-metric", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 3, + "rowSpan": 2 + }, + "allowedBlocks": [ + "metric" + ], + "required": true, + "maxItems": 1, + "priority": "normal" + }, + { + "id": "primary-chart", + "role": "primary", + "grid": { + "column": 4, + "row": 3, + "columnSpan": 6, + "rowSpan": 3 + }, + "allowedBlocks": [ + "chart" + ], + "required": true, + "maxItems": 1, + "priority": "normal" + }, + { + "id": "secondary-metrics", + "role": "secondary", + "grid": { + "column": 10, + "row": 3, + "columnSpan": 3, + "rowSpan": 3 + }, + "allowedBlocks": [ + "metric" + ], + "required": false, + "maxItems": 4, + "priority": "normal" + }, + { + "id": "supporting-view", + "role": "secondary", + "grid": { + "column": 1, + "row": 6, + "columnSpan": 9, + "rowSpan": 2 + }, + "allowedBlocks": [ + "chart", + "table", + "text" + ], + "required": false, + "maxItems": 2, + "priority": "normal" + }, + { + "id": "takeaway", + "role": "support", + "grid": { + "column": 10, + "row": 6, + "columnSpan": 3, + "rowSpan": 2 + }, + "allowedBlocks": [ + "callout", + "text" + ], + "required": false, + "maxItems": 2, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "hero-metric", + "primary-chart", + "secondary-metrics", + "supporting-view", + "takeaway" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.58, + "maxOccupiedRatio": 0.9 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "chart-focus", + "name": "Chart Focus", + "category": "Data", + "purpose": "One chart plus headline insight and annotation", + "density": "medium", + "recommendedBlocks": [ + "chart", + "caption" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "chart", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 9, + "rowSpan": 5 + }, + "allowedBlocks": [ + "chart" + ], + "required": true, + "maxItems": 1, + "priority": "normal" + }, + { + "id": "annotation", + "role": "support", + "grid": { + "column": 10, + "row": 3, + "columnSpan": 3, + "rowSpan": 3 + }, + "allowedBlocks": [ + "text", + "callout", + "metric" + ], + "required": false, + "maxItems": 3, + "priority": "normal" + }, + { + "id": "source", + "role": "source", + "grid": { + "column": 10, + "row": 6, + "columnSpan": 3, + "rowSpan": 2 + }, + "allowedBlocks": [ + "citation", + "caption", + "text" + ], + "required": false, + "maxItems": 3, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "chart", + "annotation", + "source" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.52, + "maxOccupiedRatio": 0.87 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "table-focus", + "name": "Table Focus", + "category": "Data", + "purpose": "Compact table with highlighted rows or variance", + "density": "high", + "recommendedBlocks": [ + "table" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "table", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 9, + "rowSpan": 5 + }, + "allowedBlocks": [ + "table", + "comparison" + ], + "required": true, + "maxItems": 1, + "priority": "normal" + }, + { + "id": "insight", + "role": "support", + "grid": { + "column": 10, + "row": 3, + "columnSpan": 3, + "rowSpan": 3 + }, + "allowedBlocks": [ + "callout", + "text", + "metric" + ], + "required": false, + "maxItems": 4, + "priority": "normal" + }, + { + "id": "source", + "role": "source", + "grid": { + "column": 10, + "row": 6, + "columnSpan": 3, + "rowSpan": 2 + }, + "allowedBlocks": [ + "citation", + "caption" + ], + "required": false, + "maxItems": 3, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "table", + "insight", + "source" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.54, + "maxOccupiedRatio": 0.88 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "code-focus", + "name": "Code Focus", + "category": "Technical", + "purpose": "Readable code with line emphasis and explanation", + "density": "medium", + "recommendedBlocks": [ + "code", + "text" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "code", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 8, + "rowSpan": 5 + }, + "allowedBlocks": [ + "code", + "terminal-demo" + ], + "required": true, + "maxItems": 1, + "priority": "normal" + }, + { + "id": "explanation", + "role": "support", + "grid": { + "column": 9, + "row": 3, + "columnSpan": 4, + "rowSpan": 5 + }, + "allowedBlocks": [ + "text", + "callout", + "bullets" + ], + "required": false, + "maxItems": 5, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "code", + "explanation" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.5, + "maxOccupiedRatio": 0.86 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "browser-demo", + "name": "Browser Demo", + "category": "Demo", + "purpose": "Live or simulated browser experience with callouts", + "density": "high", + "recommendedBlocks": [ + "browser-demo" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "demo", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 9, + "rowSpan": 5 + }, + "allowedBlocks": [ + "browser-demo", + "embed", + "video", + "image" + ], + "required": true, + "maxItems": 1, + "priority": "normal" + }, + { + "id": "callouts", + "role": "support", + "grid": { + "column": 10, + "row": 3, + "columnSpan": 3, + "rowSpan": 5 + }, + "allowedBlocks": [ + "text", + "callout", + "caption" + ], + "required": false, + "maxItems": 5, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "demo", + "callouts" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.54, + "maxOccupiedRatio": 0.88 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "terminal-demo", + "name": "Terminal Demo", + "category": "Demo", + "purpose": "Command sequence and result in a terminal shell", + "density": "high", + "recommendedBlocks": [ + "terminal-demo", + "code" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "terminal", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 8, + "rowSpan": 5 + }, + "allowedBlocks": [ + "terminal-demo", + "code" + ], + "required": true, + "maxItems": 1, + "priority": "normal" + }, + { + "id": "steps", + "role": "support", + "grid": { + "column": 9, + "row": 3, + "columnSpan": 4, + "rowSpan": 5 + }, + "allowedBlocks": [ + "bullets", + "process", + "text" + ], + "required": false, + "maxItems": 5, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "terminal", + "steps" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.5, + "maxOccupiedRatio": 0.86 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "device-showcase", + "name": "Device Showcase", + "category": "Product", + "purpose": "App screens inside device frames with annotations", + "density": "medium", + "recommendedBlocks": [ + "device", + "image" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "devices", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 8, + "rowSpan": 5 + }, + "allowedBlocks": [ + "device", + "image", + "gallery" + ], + "required": true, + "maxItems": 3, + "priority": "normal" + }, + { + "id": "features", + "role": "support", + "grid": { + "column": 9, + "row": 3, + "columnSpan": 4, + "rowSpan": 5 + }, + "allowedBlocks": [ + "text", + "callout", + "bullets" + ], + "required": false, + "maxItems": 5, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "devices", + "features" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.48, + "maxOccupiedRatio": 0.86 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "gallery", + "name": "Gallery", + "category": "Creative", + "purpose": "Curated image or artifact grid with consistent crops", + "density": "medium", + "recommendedBlocks": [ + "gallery" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "gallery", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 12, + "rowSpan": 4 + }, + "allowedBlocks": [ + "gallery", + "image" + ], + "required": true, + "maxItems": 8, + "priority": "normal" + }, + { + "id": "caption", + "role": "support", + "grid": { + "column": 2, + "row": 7, + "columnSpan": 10, + "rowSpan": 1 + }, + "allowedBlocks": [ + "caption", + "text" + ], + "required": false, + "maxItems": 2, + "priority": "normal", + "contentBudget": { + "maxCharacters": 180, + "maxLines": 2 + } + } + ], + "responsiveOrder": [ + "kicker", + "title", + "gallery", + "caption" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.54, + "maxOccupiedRatio": 0.9 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "team", + "name": "Team", + "category": "People", + "purpose": "Team members with roles, not decorative headshots", + "density": "medium", + "recommendedBlocks": [ + "people-grid" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "members", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 12, + "rowSpan": 4 + }, + "allowedBlocks": [ + "people-grid", + "image", + "text" + ], + "required": true, + "maxItems": 6, + "priority": "normal" + }, + { + "id": "context", + "role": "support", + "grid": { + "column": 2, + "row": 7, + "columnSpan": 10, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text", + "callout" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 160, + "maxLines": 2 + } + } + ], + "responsiveOrder": [ + "kicker", + "title", + "members", + "context" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.5, + "maxOccupiedRatio": 0.88 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "logo-wall", + "name": "Logo Wall", + "category": "Proof", + "purpose": "Customer or partner logos with grouped meaning", + "density": "low", + "recommendedBlocks": [ + "logo-wall" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "logos", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 12, + "rowSpan": 4 + }, + "allowedBlocks": [ + "logo-wall", + "gallery", + "image" + ], + "required": true, + "maxItems": 18, + "priority": "normal" + }, + { + "id": "meaning", + "role": "support", + "grid": { + "column": 2, + "row": 7, + "columnSpan": 10, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text", + "caption" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 160, + "maxLines": 2 + } + } + ], + "responsiveOrder": [ + "kicker", + "title", + "logos", + "meaning" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.48, + "maxOccupiedRatio": 0.88 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "qna", + "name": "Qna", + "category": "Closing", + "purpose": "Question prompt with supporting context", + "density": "low", + "recommendedBlocks": [ + "heading", + "text" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "context", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 80, + "maxLines": 1 + } + }, + { + "id": "prompt", + "role": "title", + "grid": { + "column": 2, + "row": 2, + "columnSpan": 9, + "rowSpan": 3 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 70, + "maxLines": 2 + } + }, + { + "id": "support", + "role": "support", + "grid": { + "column": 2, + "row": 5, + "columnSpan": 7, + "rowSpan": 2 + }, + "allowedBlocks": [ + "text", + "callout" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 180, + "maxLines": 4 + } + }, + { + "id": "contact", + "role": "footer", + "grid": { + "column": 2, + "row": 7, + "columnSpan": 7, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text", + "caption" + ], + "required": false, + "maxItems": 3, + "priority": "normal", + "contentBudget": { + "maxCharacters": 140, + "maxLines": 2 + } + }, + { + "id": "visual", + "role": "visual", + "grid": { + "column": 10, + "row": 3, + "columnSpan": 3, + "rowSpan": 3 + }, + "allowedBlocks": [ + "icon", + "image" + ], + "required": false, + "maxItems": 1, + "priority": "normal" + } + ], + "responsiveOrder": [ + "context", + "prompt", + "support", + "visual", + "contact" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.28, + "maxOccupiedRatio": 0.66 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "summary", + "name": "Summary", + "category": "Closing", + "purpose": "Three to five takeaways prioritized by importance", + "density": "medium", + "recommendedBlocks": [ + "bullets", + "callout" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "takeaways", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 8, + "rowSpan": 5 + }, + "allowedBlocks": [ + "bullets", + "callout", + "text" + ], + "required": true, + "maxItems": 5, + "priority": "normal" + }, + { + "id": "action", + "role": "secondary", + "grid": { + "column": 9, + "row": 3, + "columnSpan": 4, + "rowSpan": 5 + }, + "allowedBlocks": [ + "callout", + "metric", + "process", + "text" + ], + "required": false, + "maxItems": 4, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "takeaways", + "action" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.46, + "maxOccupiedRatio": 0.82 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "references", + "name": "References", + "category": "Closing", + "purpose": "Readable sources, links, and methodology notes", + "density": "high", + "recommendedBlocks": [ + "citations" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "kicker", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 90, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 105, + "maxLines": 2 + } + }, + { + "id": "sources", + "role": "primary", + "grid": { + "column": 1, + "row": 3, + "columnSpan": 12, + "rowSpan": 5 + }, + "allowedBlocks": [ + "citation", + "text", + "table" + ], + "required": true, + "maxItems": 18, + "priority": "normal" + } + ], + "responsiveOrder": [ + "kicker", + "title", + "sources" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.48, + "maxOccupiedRatio": 0.88 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + }, + { + "id": "closing-cta", + "name": "Closing Cta", + "category": "Closing", + "purpose": "Final action, owner, next date, and contact path", + "density": "low", + "recommendedBlocks": [ + "heading", + "callout" + ], + "responsiveRule": "Preserve reading order; stack columns below 840px and keep primary evidence before commentary.", + "qualityChecks": [ + "No clipped content at 16:9 and mobile preview.", + "One dominant focal point.", + "Minimum 32px presentation body text equivalent." + ], + "layoutContractVersion": "1.0", + "composition": { + "grid": { + "columns": 12, + "rows": 8, + "columnGap": 0.35, + "rowGap": 0.3 + }, + "slots": [ + { + "id": "context", + "role": "context", + "grid": { + "column": 1, + "row": 1, + "columnSpan": 12, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text" + ], + "required": false, + "maxItems": 1, + "priority": "normal", + "contentBudget": { + "maxCharacters": 80, + "maxLines": 1 + } + }, + { + "id": "title", + "role": "title", + "grid": { + "column": 1, + "row": 2, + "columnSpan": 8, + "rowSpan": 2 + }, + "allowedBlocks": [ + "heading" + ], + "required": true, + "maxItems": 1, + "priority": "primary", + "contentBudget": { + "maxCharacters": 75, + "maxLines": 2 + } + }, + { + "id": "action", + "role": "primary", + "grid": { + "column": 1, + "row": 4, + "columnSpan": 7, + "rowSpan": 2 + }, + "allowedBlocks": [ + "callout", + "text", + "process" + ], + "required": true, + "maxItems": 3, + "priority": "normal" + }, + { + "id": "owner-date", + "role": "support", + "grid": { + "column": 1, + "row": 6, + "columnSpan": 7, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text", + "caption" + ], + "required": false, + "maxItems": 3, + "priority": "normal", + "contentBudget": { + "maxCharacters": 150, + "maxLines": 2 + } + }, + { + "id": "contact", + "role": "footer", + "grid": { + "column": 1, + "row": 7, + "columnSpan": 7, + "rowSpan": 1 + }, + "allowedBlocks": [ + "text", + "citation" + ], + "required": false, + "maxItems": 3, + "priority": "normal", + "contentBudget": { + "maxCharacters": 140, + "maxLines": 2 + } + }, + { + "id": "visual", + "role": "visual", + "grid": { + "column": 9, + "row": 2, + "columnSpan": 4, + "rowSpan": 5 + }, + "allowedBlocks": [ + "image", + "video", + "gallery", + "browser-demo", + "device", + "embed", + "diagram", + "chart", + "map", + "code", + "terminal-demo", + "icon" + ], + "required": false, + "maxItems": 1, + "priority": "normal" + } + ], + "responsiveOrder": [ + "context", + "title", + "action", + "owner-date", + "visual", + "contact" + ], + "collisionPolicy": { + "mode": "forbid-content-overlap", + "allowedOverlapRoles": [ + "background", + "decoration", + "annotation" + ], + "maxIncidentalOverlapRatio": 0.02 + }, + "whitespaceTarget": { + "minOccupiedRatio": 0.34, + "maxOccupiedRatio": 0.74 + }, + "freeformAllowed": false, + "notes": "Bind normal content to semantic slots. Use freeform only for explicit user-positioned annotations or diagram internals." + }, + "defaultPositionMode": "slot", + "freeformPolicy": "explicit-only" + } +] diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/deck/layout.ts b/examples/02-example/.agents/skills/deckforge/starter-components/deck/layout.ts new file mode 100644 index 0000000..960b972 --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/deck/layout.ts @@ -0,0 +1,382 @@ +import layoutManifest from './layout-manifest.json'; +import type { Block, DeckProject, DeckSlide, Frame, LayoutBinding } from './types'; + +/** + * Typed slot roles for semantic block-slot matching. + * + * Use these roles instead of arbitrary string IDs to ensure + * deterministic compatibility between blocks and slots. + */ +export type SlotRole = + | 'title' + | 'subtitle' + | 'kicker' + | 'body' + | 'visual' + | 'chart' + | 'callout' + | 'image' + | 'process' + | 'citation' + | 'footer' + | 'context' + | 'metric' + | 'meaning' + | 'evidence' + | 'caption' + | 'meta' + | 'steps' + | 'decision' + | 'support' + | 'left' + | 'right' + | 'column-1' + | 'column-2' + | 'column-3' + | 'content' + | 'accent' + | 'devices' + | 'gallery' + | 'demo' + | 'map' + | 'option-a' + | 'option-b' + | 'before' + | 'after' + | 'impact' + | 'takeaway' + | 'outcome' + | 'interpretation' + | 'meaning-alt' + | 'timeline' + | 'source' + | 'contact' + | string; // Allow custom roles for extensibility + +export interface LayoutSlotContract { + id: string; + role: SlotRole; + grid: { column: number; row: number; columnSpan: number; rowSpan: number }; + allowedBlocks?: string[]; + required?: boolean; + maxItems?: number; + priority?: string; + contentBudget?: { maxCharacters?: number; maxLines?: number }; +} + +export interface LayoutCompositionContract { + grid: { columns: number; rows: number; columnGap: number; rowGap: number }; + slots: LayoutSlotContract[]; + responsiveOrder?: string[]; + collisionPolicy?: { + mode?: string; + allowedOverlapRoles?: string[]; + maxIncidentalOverlapRatio?: number; + }; + whitespaceTarget?: { minOccupiedRatio?: number; maxOccupiedRatio?: number }; + freeformAllowed?: boolean; +} + +export interface LayoutContract { + id: string; + name: string; + category: string; + purpose: string; + density: string; + composition: LayoutCompositionContract; + defaultPositionMode?: string; + freeformPolicy?: string; + responsiveRule?: string; +} + +export interface ResolvedFrame extends Frame { + slot: string; + role: string; +} + +export type { LayoutBinding }; + +export function getLayoutContract(layoutId: string): LayoutContract | undefined { + return (layoutManifest as LayoutContract[]).find((layout) => layout.id === layoutId); +} + +export function listLayouts(): LayoutContract[] { + return layoutManifest as LayoutContract[]; +} + +/** + * Resolve a slot's grid geometry into canvas coordinates. + * Mirrors scripts/audits/audit_deck_layout.py's resolve_slot so editor rendering + * matches the deterministic audit exactly. + */ +export function resolveSlotFrame( + slot: LayoutSlotContract, + canvas: DeckProject['canvas'], +): Frame { + const safe = canvas.safeMargin ?? 64; + const w = canvas.width ?? 1600; + const h = canvas.height ?? 900; + const innerW = w - 2 * safe; + const innerH = h - 2 * safe; + const cg = 0.35; + const rg = 0.3; + const colGap = 16 * (cg / 0.35); + const rowGap = 16 * (rg / 0.3); + const unitW = (innerW - colGap * 11) / 12; + const unitH = (innerH - rowGap * 7) / 8; + const g = slot.grid; + const x = safe + (g.column - 1) * (unitW + colGap); + const y = safe + (g.row - 1) * (unitH + rowGap); + const sw = g.columnSpan * unitW + (g.columnSpan - 1) * colGap; + const sh = g.rowSpan * unitH + (g.rowSpan - 1) * rowGap; + return { x: Math.round(x), y: Math.round(y), w: Math.round(sw), h: Math.round(sh) }; +} + +export interface ResolvedSlot { + slot: LayoutSlotContract; + frame: Frame; +} + +/** + * Resolve all slots for a layout into frames. Slots are returned in + * responsiveOrder (falling back to manifest order) so reading order is + * deterministic and not coordinate-driven. + */ +export function resolveLayout( + layoutId: string, + canvas: DeckProject['canvas'], +): ResolvedSlot[] { + const contract = getLayoutContract(layoutId); + if (!contract?.composition) return []; + const slots = contract.composition.slots; + const order = contract.composition.responsiveOrder ?? slots.map((slot) => slot.id); + const byId = new Map(slots.map((slot) => [slot.id, slot])); + const ordered = order + .map((id) => byId.get(id)) + .filter((slot): slot is LayoutSlotContract => Boolean(slot)); + for (const slot of slots) { + if (!ordered.includes(slot)) ordered.push(slot); + } + return ordered.map((slot) => ({ slot, frame: resolveSlotFrame(slot, canvas) })); +} + +export interface BlockPlacement { + blockId: string; + slotId: string; + slot: LayoutSlotContract; + frame: Frame; +} + +/** Slot/flow layer entry: a block bound to a semantic slot frame. */ +export interface SlotFlowEntry { + block: Block; + placement: BlockPlacement; +} + +/** Freeform layer entry: an explicitly positioned block on its own frame. */ +export interface FreeformEntry { + block: Block; + frame: Frame; +} + +/** Background layer entry: a full-canvas (or framed) block rendered behind slots. */ +export interface BackgroundEntry { + block: Block; + frame?: Frame; +} + +/** + * Exclusive rendering layers (P0-005): each block id appears in EXACTLY ONE + * bucket. Layer order is background → semantic slot/flow → freeform, with a + * system overlay rendered separately by the host. + */ +export interface LayerAssignment { + background: BackgroundEntry[]; + slotFlow: SlotFlowEntry[]; + freeform: FreeformEntry[]; +} + +/** Position modes that must never participate in the semantic slot pass. */ +const NON_SLOT_MODES: ReadonlySet<string> = new Set(['freeform', 'background']); + +/** + * Bind blocks to slot frames for a slide using its layoutBindings. + * Returns placements in responsive (slot) order for stable reading order. + * + * Blocks whose positionMode is 'freeform' or 'background' are excluded from + * the slot pass even when a binding lists them, so they can never be placed on + * the slot layer (P0-005). + */ +export function resolveSlidePlacements( + slide: DeckSlide, + canvas: DeckProject['canvas'], +): BlockPlacement[] { + const byId = new Map(slide.blocks.map((block) => [block.id, block])); + const resolved = resolveLayout(slide.layout, canvas); + const bySlot = new Map(resolved.map((entry) => [entry.slot.id, entry])); + const bindingMap = new Map<string, LayoutBinding>(); + for (const binding of slide.layoutBindings ?? []) { + bindingMap.set(binding.slot, binding); + } + const placements: BlockPlacement[] = []; + for (const entry of resolved) { + const binding = bindingMap.get(entry.slot.id); + if (!binding) continue; + for (const blockId of binding.blockIds) { + const block = byId.get(blockId); + if (block && NON_SLOT_MODES.has(block.positionMode ?? '')) continue; + placements.push({ + blockId, + slotId: entry.slot.id, + slot: entry.slot, + frame: entry.frame, + }); + } + } + return placements; +} + +/** + * Assign every block on a slide to exactly one rendering layer (P0-005): + * + * - `slotFlow` — blocks bound to semantic slots (positionMode slot/flow). + * - `freeform` — blocks with positionMode 'freeform', at their own frame. + * - `background` — blocks with positionMode 'background', full-canvas or framed. + * + * The invariant is that each block id lands in exactly one bucket. A block + * listed in a binding but flagged freeform/background stays off the slot pass. + * Violations (double assignment, missing frame, or orphan blocks) are reported + * via console.error so renderers stay resilient while the invariant is visible. + */ +export function assignBlocksToLayers( + slide: DeckSlide, + canvas: DeckProject['canvas'], +): LayerAssignment { + const byId = new Map(slide.blocks.map((block) => [block.id, block])); + const background: BackgroundEntry[] = []; + const slotFlow: SlotFlowEntry[] = []; + const freeform: FreeformEntry[] = []; + + const assigned = new Set<string>(); + const mark = (block: Block): boolean => { + if (assigned.has(block.id)) { + console.error(`[deckforge] P0-005 invariant violation: block "${block.id}" assigned to more than one layer`); + return false; + } + assigned.add(block.id); + return true; + }; + + for (const placement of resolveSlidePlacements(slide, canvas)) { + const block = byId.get(placement.blockId); + if (!block || !mark(block)) continue; + slotFlow.push({ block, placement }); + } + + for (const block of slide.blocks) { + if (block.positionMode !== 'freeform') continue; + const frame = block.frame; + if (!frame) { + console.error(`[deckforge] P0-005 invariant violation: freeform block "${block.id}" has no frame`); + continue; + } + if (!mark(block)) continue; + freeform.push({ block, frame }); + } + + for (const block of slide.blocks) { + if (block.positionMode !== 'background') continue; + if (!mark(block)) continue; + background.push({ block, frame: block.frame }); + } + + for (const block of slide.blocks) { + if (!assigned.has(block.id)) { + console.error(`[deckforge] P0-005 invariant violation: block "${block.id}" was not assigned to any layer`); + } + } + + return { background, slotFlow, freeform }; +} + +/** Returns the frame a specific block resolves to, if bound. */ +export function resolveBlockFrame( + slide: DeckSlide, + canvas: DeckProject['canvas'], + blockId: string, +): Frame | undefined { + return resolveSlidePlacements(slide, canvas).find((placement) => placement.blockId === blockId)?.frame; +} + +/** Warnings for the editor: empty required slots, over-budget slots. */ +/** + * Pick the best slot to bind a newly inserted block to. Prefers the first + * responsive slot whose `allowedBlocks` accepts the type and that still has + * room (maxItems not reached). Next prefers a type-compatible slot even when + * it is at capacity (soft overflow), so a new block never lands in a slot + * that rejects its type (e.g. an image in a text-only band producing a + * degenerate frame). Falls back to the first slot with room so an insert + * always renders instead of disappearing into state-only. + */ +export function suggestSlotForBlock(slide: DeckSlide, block: Block): string | undefined { + const contract = getLayoutContract(slide.layout); + if (!contract?.composition?.slots.length) return undefined; + const bindings = new Map<string, LayoutBinding>(); + for (const binding of slide.layoutBindings ?? []) bindings.set(binding.slot, binding); + const order = contract.composition.responsiveOrder ?? contract.composition.slots.map((slot) => slot.id); + const ordered = [...order, ...contract.composition.slots.map((slot) => slot.id)]; + const seen = new Set<string>(); + const slots: LayoutSlotContract[] = []; + for (const id of ordered) { + if (seen.has(id)) continue; + seen.add(id); + const slot = contract.composition.slots.find((candidate) => candidate.id === id); + if (slot) slots.push(slot); + } + const hasRoom = (slot: LayoutSlotContract): boolean => { + const count = bindings.get(slot.id)?.blockIds.length ?? 0; + return slot.maxItems == null || count < slot.maxItems; + }; + const allows = (slot: LayoutSlotContract): boolean => + !slot.allowedBlocks?.length || slot.allowedBlocks.includes(block.type); + return ( + slots.find((slot) => allows(slot) && hasRoom(slot))?.id ?? + slots.find((slot) => allows(slot))?.id ?? + slots.find(hasRoom)?.id + ); +} + +export interface LayoutIssue { + severity: 'warning' | 'error'; + slot: string; + message: string; +} + +export function auditSlideLayout( + slide: DeckSlide, + canvas: DeckProject['canvas'], +): LayoutIssue[] { + const issues: LayoutIssue[] = []; + const resolved = resolveLayout(slide.layout, canvas); + const bindings = new Map<string, LayoutBinding>(); + for (const binding of slide.layoutBindings ?? []) bindings.set(binding.slot, binding); + for (const entry of resolved) { + const binding = bindings.get(entry.slot.id); + const count = binding?.blockIds.length ?? 0; + if (entry.slot.required && count === 0) { + issues.push({ + severity: 'error', + slot: entry.slot.id, + message: `Required slot "${entry.slot.id}" is empty`, + }); + } + if (entry.slot.maxItems != null && count > entry.slot.maxItems) { + issues.push({ + severity: 'warning', + slot: entry.slot.id, + message: `Slot "${entry.slot.id}" has ${count} blocks, max is ${entry.slot.maxItems}`, + }); + } + } + return issues; +} diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/deck/scrollbars/scrollbarTypes.ts b/examples/02-example/.agents/skills/deckforge/starter-components/deck/scrollbars/scrollbarTypes.ts new file mode 100644 index 0000000..3e8fe0c --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/deck/scrollbars/scrollbarTypes.ts @@ -0,0 +1,37 @@ +export type ScrollSurface = + | 'app-page' + | 'slide-list' + | 'inspector' + | 'grid' + | 'speaker-notes' + | 'modal' + | 'asset-library' + | 'theme-library' + | 'presenter' + | 'slide-stage'; + +export type ScrollbarStyleId = + | 'gradient-slim' + | 'aurora-glow' + | 'minimal-thin' + | 'neon-edge' + | 'mono-ink' + | 'high-contrast' + | 'system-native' + | 'none'; + +export type ScrollAxis = 'vertical' | 'horizontal' | 'both'; + +export interface ScrollbarThemeMapping { + default: ScrollbarStyleId; + 'app-page'?: ScrollbarStyleId; + 'slide-list'?: ScrollbarStyleId; + inspector?: ScrollbarStyleId; + grid?: ScrollbarStyleId; + 'speaker-notes'?: ScrollbarStyleId; + modal?: ScrollbarStyleId; + 'asset-library'?: ScrollbarStyleId; + 'theme-library'?: ScrollbarStyleId; + presenter: 'none'; + 'slide-stage': 'none'; +} diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/deck/seed.ts b/examples/02-example/.agents/skills/deckforge/starter-components/deck/seed.ts new file mode 100644 index 0000000..6178767 --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/deck/seed.ts @@ -0,0 +1,279 @@ +import type { Block, DeckProject, DeckSlide } from './types'; +import { getLayoutContract, suggestSlotForBlock, type LayoutSlotContract } from './layout'; + + +export function newId(prefix = 'b'): string { + return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +export function makeTextBlock(id: string, content: string, slot: string): Block { + return { + id, + type: 'text', + content, + style: {}, + sourceIds: [], + slot, + positionMode: 'slot', + }; +} + +export function makeHeadingBlock(id: string, content: string, slot: string, level = 3): Block { + return { + id, + type: 'heading', + content, + style: { level }, + sourceIds: [], + slot, + positionMode: 'slot', + }; +} + +/** Rebind blocks from a legacy layout to a new layout by best-effort slot mapping. */ +const SLOT_ALIASES: Record<string, string[]> = { + title: ['title'], + kicker: ['kicker', 'context', 'chapter'], + subtitle: ['subtitle', 'support', 'interpretation', 'meaning'], + visual: ['visual', 'chart', 'map', 'devices', 'gallery', 'demo', 'accent'], + 'option-a': ['option-a', 'before', 'left', 'column-1'], + 'option-b': ['option-b', 'after', 'right', 'column-2'], + decision: ['decision', 'impact', 'takeaway', 'outcome'], + steps: ['steps', 'timeline', 'process'], + footer: ['footer', 'contact', 'source', 'caption', 'meta'], +}; + +const SLOT_ALIAS_REVERSE: Record<string, string> = Object.fromEntries( + Object.entries(SLOT_ALIASES).flatMap(([target, aliases]) => aliases.map((alias) => [alias, target])), +); + +/** Generic fallback slots that accept most block types when no better match exists. */ +const GENERIC_SLOTS = ['left', 'right', 'column-1', 'column-2', 'column-3', 'support', 'content', 'body']; + +function slotAccepts(slot: LayoutSlotContract, type: string): boolean { + return !slot.allowedBlocks?.length || slot.allowedBlocks.includes(type); +} + +/** + * Lossless layout migration (P0-003, DF-012). + * + * Rebinds every non-background, non-freeform block to a slot in the target + * layout so no block disappears when a layout changes. Mapping priority: + * + * 1. The block's current slot, when it exists in the target layout and accepts + * the block type. + * 2. A semantic alias slot in the target layout that accepts the block type. + * 3. A generic slot in the target layout that accepts the block type. + * 4. Any target slot with remaining capacity (never drops a block). + * + * Freeform and background blocks are left untouched: they live on their own + * rendering layer and are not bound to slots. + */ +export function migrateLayoutBindings( + slide: DeckSlide, + newLayout: string, +): DeckSlide { + const contract = getLayoutContract(newLayout); + if (!contract?.composition?.slots.length) { + return { ...slide, layout: newLayout, layoutBindings: slide.layoutBindings }; + } + + const slots = contract.composition.slots; + const responsiveOrder = contract.composition.responsiveOrder ?? slots.map((slot) => slot.id); + const slotById = new Map(slots.map((slot) => [slot.id, slot])); + const slotCapacity = new Map<string, number>(); + for (const slot of slots) { + slotCapacity.set(slot.id, slot.maxItems ?? Number.POSITIVE_INFINITY); + } + + const targetIds = new Set(slots.map((slot) => slot.id)); + const slotBlocks = slide.blocks.filter((block) => block.positionMode !== 'background' && block.positionMode !== 'freeform'); + const boundCounts = new Map<string, number>(); + const assigned = new Set<string>(); + const bindingMap = new Map<string, string[]>(); + + const place = (blockId: string, slotId: string) => { + const used = boundCounts.get(slotId) ?? 0; + if (used >= slotCapacity.get(slotId)!) return false; + const list = bindingMap.get(slotId) ?? []; + bindingMap.set(slotId, [...list, blockId]); + boundCounts.set(slotId, used + 1); + assigned.add(blockId); + return true; + }; + + /** Place without a capacity check; used only as a last resort to avoid data loss. */ + const placeUnchecked = (slotId: string, blockId: string) => { + const list = bindingMap.get(slotId) ?? []; + bindingMap.set(slotId, [...list, blockId]); + boundCounts.set(slotId, (boundCounts.get(slotId) ?? 0) + 1); + assigned.add(blockId); + return true; + }; + + const candidateSlotsFor = (block: Block): string[] => { + const current = block.slot; + const candidates = new Set<string>(); + if (current && targetIds.has(current)) candidates.add(current); + const alias = current ? SLOT_ALIAS_REVERSE[current] : undefined; + if (alias && targetIds.has(alias)) candidates.add(alias); + for (const generic of GENERIC_SLOTS) { + if (targetIds.has(generic)) candidates.add(generic); + } + // Fallback: any target slot (keep deterministic order, required slots first). + const ordered = [...slots].sort((a, b) => Number(Boolean(b.required)) - Number(Boolean(a.required))); + for (const slot of ordered) candidates.add(slot.id); + return [...candidates]; + }; + + for (const block of slotBlocks) { + if (assigned.has(block.id)) continue; + const candidates = candidateSlotsFor(block); + const chosen = candidates.find((slotId) => slotAccepts(slotById.get(slotId)!, block.type) && place(block.id, slotId)); + if (!chosen) { + // Last resort: bind to any slot that accepts the block type even when at + // capacity (soft overflow is a warning, not data loss), then to any slot + // regardless of allowedBlocks so a block never silently disappears. + const accepting = [...slots].find((slot) => slotAccepts(slot, block.type) && placeUnchecked(slot.id, block.id)); + if (!accepting) { + for (const slot of slots) { + if (placeUnchecked(slot.id, block.id)) break; + } + } + } + } + + const layoutBindings = responsiveOrder + .filter((slotId) => bindingMap.has(slotId)) + .map((slotId) => ({ + slot: slotId, + blockIds: bindingMap.get(slotId)!, + flow: 'stack' as const, + gap: 10, + })); + + return { + ...slide, + layout: newLayout, + layoutBindings, + }; +} + +/** + * Legacy block migration (P0-003, DF-012). + * + * Repairs stale blocks that have positionMode "slot" but: + * - No slot property (MISSING_SLOT_ID) + * - A slot that doesn't exist in the layout (UNKNOWN_SLOT) + * - A slot that doesn't accept the block type (SLOT_TYPE_MISMATCH) + * + * This migration runs automatically when loading legacy documents + * to ensure all blocks are exportable without manual repair. + * + * Returns a NEW slide with repaired blocks and bindings. + * The input slide is never mutated. + */ +export function migrateLegacyBlockSlots(slide: DeckSlide): DeckSlide { + const contract = getLayoutContract(slide.layout); + if (!contract?.composition?.slots.length) return slide; + + const slots = contract.composition.slots; + const slotById = new Map(slots.map((slot) => [slot.id, slot])); + const slotCapacity = new Map<string, number>(); + for (const slot of slots) { + slotCapacity.set(slot.id, slot.maxItems ?? Number.POSITIVE_INFINITY); + } + + // Build current bindings map + const bindingMap = new Map<string, string[]>(); + for (const binding of slide.layoutBindings ?? []) { + bindingMap.set(binding.slot, [...binding.blockIds]); + } + + // Track which blocks are bound + const boundCounts = new Map<string, number>(); + for (const [slotId, ids] of bindingMap) { + boundCounts.set(slotId, ids.length); + } + + const needsRepair: Block[] = []; + const repairedBlocks: Block[] = []; + + for (const block of slide.blocks) { + if (block.hidden) continue; + if (block.positionMode === 'freeform' || block.positionMode === 'background') { + repairedBlocks.push(block); + continue; + } + + // Check if block needs repair + const slotContract = block.slot ? slotById.get(block.slot) : undefined; + const needsSlotRepair = !block.slot || !slotContract || !slotAccepts(slotContract, block.type); + + if (needsSlotRepair) { + needsRepair.push(block); + } else { + repairedBlocks.push(block); + } + } + + if (needsRepair.length === 0) return slide; + + // Repair each block using the same logic as suggestSlotForBlock + for (const block of needsRepair) { + const slideWithBlock: DeckSlide = { + ...slide, + blocks: [...slide.blocks, block], + }; + const suggestedSlot = suggestSlotForBlock(slideWithBlock, block); + + if (suggestedSlot) { + // Add block to the suggested slot's binding + if (!bindingMap.has(suggestedSlot)) { + bindingMap.set(suggestedSlot, []); + } + bindingMap.get(suggestedSlot)!.push(block.id); + boundCounts.set(suggestedSlot, (boundCounts.get(suggestedSlot) ?? 0) + 1); + + // Add repaired block + repairedBlocks.push({ + ...block, + slot: suggestedSlot, + positionMode: 'slot', + }); + } else { + // No slot found — keep block as-is (will be caught by geometry resolver) + repairedBlocks.push(block); + } + } + + // Build new layoutBindings + const responsiveOrder = contract.composition.responsiveOrder ?? slots.map((s) => s.id); + const layoutBindings = responsiveOrder + .filter((slotId) => bindingMap.has(slotId)) + .map((slotId) => ({ + slot: slotId, + blockIds: bindingMap.get(slotId)!, + flow: 'stack' as const, + gap: 10, + })); + + return { + ...slide, + blocks: repairedBlocks, + layoutBindings, + }; +} + +/** + * Migrate all legacy blocks in a deck. + * + * Returns a NEW deck with repaired blocks and bindings. + * The input deck is never mutated. + */ +export function migrateLegacyDeckSlots(deck: DeckProject): DeckProject { + return { + ...deck, + slides: deck.slides.map((slide) => migrateLegacyBlockSlots(slide)), + }; +} diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/deck/slot-validation.ts b/examples/02-example/.agents/skills/deckforge/starter-components/deck/slot-validation.ts new file mode 100644 index 0000000..f2768e4 --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/deck/slot-validation.ts @@ -0,0 +1,469 @@ +/** + * deck/slot-validation.ts + * + * Creation-time validation and auto-repair for slot-positioned blocks. + * + * This module ensures that every block with positionMode "slot" satisfies + * the strict positioning contract BEFORE export: + * + * 1. slotId exists on the block + * 2. slotId references a slot in the active layout + * 3. The slot accepts the block type (allowedBlocks) + * 4. The slot has remaining capacity (maxItems) + * + * Invariants: + * - Never persist a block with positionMode "slot" and no valid slotId + * - Never persist a block referencing a nonexistent slot + * - Auto-repair is deterministic and uses the same logic as the runtime resolver + * - Preflight should normally pass immediately for a correctly generated deck + */ + +import type { Block, DeckProject, DeckSlide, LayoutBinding } from './types'; +import { + getLayoutContract, + resolveLayout, + suggestSlotForBlock, + type LayoutSlotContract, +} from './layout'; + +// ─── Error Types ─────────────────────────────────────────────────────────── + +export type SlotValidationErrorKind = + | 'MISSING_SLOT_ID' + | 'UNKNOWN_SLOT' + | 'SLOT_TYPE_MISMATCH' + | 'SLOT_CAPACITY_EXCEEDED' + | 'MISSING_LAYOUT' + | 'MISSING_FRAME' + | 'NON_FINITE_GEOMETRY' + | 'INVALID_SIZE'; + +export interface SlotValidationError { + kind: SlotValidationErrorKind; + blockId: string; + blockType: string; + slotId?: string; + layoutId?: string; + message: string; + /** The slot role the block was trying to target, if determinable. */ + requestedRole?: string; + /** Available slots that could accept this block type. */ + availableSlots?: string[]; +} + +export interface BlockValidationResult { + valid: boolean; + errors: SlotValidationError[]; + /** The slot the block should be bound to after repair. */ + repairedSlotId?: string; + /** Whether the block's slot property was changed during repair. */ + slotChanged?: boolean; + /** Whether a new binding was created during repair. */ + bindingCreated?: boolean; +} + +export interface SlideValidationResult { + valid: boolean; + blockResults: Map<string, BlockValidationResult>; + totalErrors: number; + /** Repaired slide with corrected bindings. */ + repairedSlide?: DeckSlide; +} + +// ─── Slot Acceptance Logic ───────────────────────────────────────────────── + +/** + * Check if a slot accepts a block type. + * Uses the same logic as seed.ts and geometry-resolver.ts for consistency. + */ +export function slotAccepts(slot: LayoutSlotContract | undefined, type: string): boolean { + return !slot?.allowedBlocks?.length || slot.allowedBlocks.includes(type); +} + +/** + * Check if a slot has remaining capacity. + */ +export function slotHasRoom( + slot: LayoutSlotContract, + currentBindings: Map<string, LayoutBinding>, +): boolean { + const count = currentBindings.get(slot.id)?.blockIds.length ?? 0; + return slot.maxItems == null || count < slot.maxItems; +} + +// ─── Single Block Validation ─────────────────────────────────────────────── + +/** + * Validate a single block's positioning contract. + * + * Returns a BlockValidationResult with: + * - valid: true if the block satisfies the contract + * - errors: list of validation errors + * - repairedSlotId: the slot the block should be bound to (if repair is possible) + */ +export function validateBlockPositioning( + block: Block, + slide: DeckSlide, + canvas: DeckProject['canvas'], +): BlockValidationResult { + const errors: SlotValidationError[] = []; + const layoutId = slide.layout; + + // Freeform and background blocks are not validated for slot positioning + if (block.positionMode === 'freeform' || block.positionMode === 'background') { + // But they still need valid frames + if (!block.frame) { + const frame = block.resolvedFrame; + if (!frame) { + errors.push({ + kind: 'MISSING_FRAME', + blockId: block.id, + blockType: block.type, + layoutId, + message: `${block.type} block "${block.id}" has positionMode "${block.positionMode}" but no frame`, + }); + } else if (!Number.isFinite(frame.x) || !Number.isFinite(frame.y) || !Number.isFinite(frame.w) || !Number.isFinite(frame.h)) { + errors.push({ + kind: 'NON_FINITE_GEOMETRY', + blockId: block.id, + blockType: block.type, + layoutId, + message: `${block.type} block "${block.id}" has non-finite frame dimensions`, + }); + } else if (frame.w <= 0 || frame.h <= 0) { + errors.push({ + kind: 'INVALID_SIZE', + blockId: block.id, + blockType: block.type, + layoutId, + message: `${block.type} block "${block.id}" has zero or negative frame dimensions`, + }); + } + } + return { valid: errors.length === 0, errors }; + } + + // Slot-positioned blocks need a valid layout + const contract = getLayoutContract(layoutId); + if (!contract?.composition?.slots.length) { + // No layout defined — use suggestSlotForBlock as fallback + const suggestedSlot = suggestSlotForBlock(slide, block); + if (suggestedSlot) { + return { + valid: true, + errors: [], + repairedSlotId: suggestedSlot, + slotChanged: block.slot !== suggestedSlot, + }; + } + errors.push({ + kind: 'MISSING_LAYOUT', + blockId: block.id, + blockType: block.type, + layoutId, + message: `No layout contract found for "${layoutId}"`, + }); + return { valid: false, errors }; + } + + // Check if block has a slot property + if (!block.slot) { + // Auto-repair: find best matching slot + const suggestedSlot = suggestSlotForBlock(slide, block); + if (suggestedSlot) { + return { + valid: true, + errors: [], + repairedSlotId: suggestedSlot, + slotChanged: true, + }; + } + errors.push({ + kind: 'MISSING_SLOT_ID', + blockId: block.id, + blockType: block.type, + layoutId, + message: `${block.type} block "${block.id}" has positionMode "slot" but no slotId`, + }); + return { valid: false, errors }; + } + + // Check if the slot exists in the layout + const slotContract = contract.composition.slots.find((s) => s.id === block.slot); + if (!slotContract) { + // Auto-repair: find best matching slot + const suggestedSlot = suggestSlotForBlock(slide, block); + if (suggestedSlot) { + return { + valid: true, + errors: [], + repairedSlotId: suggestedSlot, + slotChanged: true, + }; + } + errors.push({ + kind: 'UNKNOWN_SLOT', + blockId: block.id, + blockType: block.type, + slotId: block.slot, + layoutId, + message: `Slot "${block.slot}" does not exist in layout "${layoutId}"`, + availableSlots: contract.composition.slots.map((s) => s.id), + }); + return { valid: false, errors }; + } + + // Check if the slot accepts this block type + if (!slotAccepts(slotContract, block.type)) { + // Auto-repair: find best matching slot + const suggestedSlot = suggestSlotForBlock(slide, block); + if (suggestedSlot) { + return { + valid: true, + errors: [], + repairedSlotId: suggestedSlot, + slotChanged: true, + }; + } + errors.push({ + kind: 'SLOT_TYPE_MISMATCH', + blockId: block.id, + blockType: block.type, + slotId: block.slot, + layoutId, + message: `Slot "${block.slot}" does not accept ${block.type} blocks (allowedBlocks: ${slotContract.allowedBlocks?.join(', ') ?? 'any'})`, + availableSlots: contract.composition.slots + .filter((s) => slotAccepts(s, block.type)) + .map((s) => s.id), + }); + return { valid: false, errors }; + } + + // Block satisfies the positioning contract + return { valid: true, errors: [] }; +} + +// ─── Slide Validation ────────────────────────────────────────────────────── + +/** + * Validate all blocks on a slide for slot positioning. + * + * Returns a SlideValidationResult with per-block results and the total error count. + */ +export function validateSlideSlotBindings( + slide: DeckSlide, + canvas: DeckProject['canvas'], +): SlideValidationResult { + const blockResults = new Map<string, BlockValidationResult>(); + let totalErrors = 0; + + for (const block of slide.blocks) { + if (block.hidden) continue; + const result = validateBlockPositioning(block, slide, canvas); + blockResults.set(block.id, result); + totalErrors += result.errors.length; + } + + return { + valid: totalErrors === 0, + blockResults, + totalErrors, + }; +} + +// ─── Auto-Repair ─────────────────────────────────────────────────────────── + +/** + * Repair a single invalid slot block by binding it to the best matching slot. + * + * Returns a new block with the corrected slot property. + */ +export function repairSlotBlock( + block: Block, + slide: DeckSlide, + canvas: DeckProject['canvas'], +): Block { + const suggestedSlot = suggestSlotForBlock(slide, block); + if (!suggestedSlot) return block; + return { ...block, slot: suggestedSlot, positionMode: 'slot' }; +} + +/** + * Repair all invalid slot blocks on a slide. + * + * Returns a new slide with corrected layoutBindings. + * The input slide is never mutated. + */ +export function repairSlideSlotBindings( + slide: DeckSlide, + canvas: DeckProject['canvas'], +): DeckSlide { + const contract = getLayoutContract(slide.layout); + if (!contract?.composition?.slots.length) return slide; + + // Build current bindings map + const bindings = new Map<string, LayoutBinding>(); + for (const binding of slide.layoutBindings ?? []) { + bindings.set(binding.slot, binding); + } + + // Track which blocks are already bound + const boundBlockIds = new Set<string>(); + for (const binding of slide.layoutBindings ?? []) { + for (const id of binding.blockIds) { + boundBlockIds.add(id); + } + } + + // Find blocks that need repair + const blocksToRepair: Block[] = []; + for (const block of slide.blocks) { + if (block.hidden) continue; + if (block.positionMode === 'freeform' || block.positionMode === 'background') continue; + if (boundBlockIds.has(block.id)) continue; + + const result = validateBlockPositioning(block, slide, canvas); + if (!result.valid || result.repairedSlotId) { + blocksToRepair.push(block); + } + } + + if (blocksToRepair.length === 0) return slide; + + // Repair each block + const repairedBlocks: Block[] = []; + const newBindings = new Map<string, string[]>(); + + // Initialize with existing bindings + for (const [slotId, binding] of bindings) { + newBindings.set(slotId, [...binding.blockIds]); + } + + for (const block of blocksToRepair) { + const result = validateBlockPositioning(block, slide, canvas); + const repairedSlot = result.repairedSlotId ?? suggestSlotForBlock(slide, block); + + if (repairedSlot) { + // Add block to the repaired slot's binding + if (!newBindings.has(repairedSlot)) { + newBindings.set(repairedSlot, []); + } + newBindings.get(repairedSlot)!.push(block.id); + + // Add repaired block to the list + repairedBlocks.push({ + ...block, + slot: repairedSlot, + positionMode: 'slot', + }); + } else { + // No slot found — keep block as-is (will be caught by geometry resolver) + repairedBlocks.push(block); + } + } + + // Build new layoutBindings + const layoutBindings: LayoutBinding[] = []; + const slotOrder = contract.composition.responsiveOrder ?? contract.composition.slots.map((s) => s.id); + + for (const slotId of slotOrder) { + const blockIds = newBindings.get(slotId); + if (blockIds && blockIds.length > 0) { + const existingBinding = bindings.get(slotId); + layoutBindings.push({ + slot: slotId, + blockIds, + flow: existingBinding?.flow ?? 'stack', + gap: existingBinding?.gap ?? 8, + }); + } + } + + // Merge repaired blocks with original blocks + const blockById = new Map(slide.blocks.map((b) => [b.id, b])); + for (const repaired of repairedBlocks) { + blockById.set(repaired.id, repaired); + } + + return { + ...slide, + blocks: [...blockById.values()], + layoutBindings, + }; +} + +// ─── Deck Validation ─────────────────────────────────────────────────────── + +/** + * Validate all blocks in a deck for slot positioning. + * + * Returns validation results for each slide. + */ +export function validateDeckSlotBindings( + deck: DeckProject, +): Map<string, SlideValidationResult> { + const canvas = deck.canvas ?? { aspectRatio: '16:9', width: 1600, height: 900, safeMargin: 64 }; + const results = new Map<string, SlideValidationResult>(); + + for (const slide of deck.slides) { + results.set(slide.id, validateSlideSlotBindings(slide, canvas)); + } + + return results; +} + +/** + * Repair all invalid slot blocks in a deck. + * + * Returns a new deck with corrected layoutBindings. + * The input deck is never mutated. + */ +export function repairDeckSlotBindings(deck: DeckProject): DeckProject { + const canvas = deck.canvas ?? { aspectRatio: '16:9', width: 1600, height: 900, safeMargin: 64 }; + return { + ...deck, + slides: deck.slides.map((slide) => repairSlideSlotBindings(slide, canvas)), + }; +} + +// ─── Exportability Gate ───────────────────────────────────────────────────── + +/** + * Check if a deck is exportable (all blocks have valid geometry). + * + * This is a lightweight preflight check that can be run after generation + * to ensure the deck is ready for export without manual repairs. + */ +export function isDeckExportable(deck: DeckProject): { + exportable: boolean; + errors: SlotValidationError[]; + totalBlocks: number; + validBlocks: number; + invalidBlocks: number; +} { + const results = validateDeckSlotBindings(deck); + const allErrors: SlotValidationError[] = []; + let totalBlocks = 0; + let validBlocks = 0; + let invalidBlocks = 0; + + for (const [slideId, result] of results) { + for (const [blockId, blockResult] of result.blockResults) { + totalBlocks++; + if (blockResult.valid) { + validBlocks++; + } else { + invalidBlocks++; + allErrors.push(...blockResult.errors); + } + } + } + + return { + exportable: allErrors.length === 0, + errors: allErrors, + totalBlocks, + validBlocks, + invalidBlocks, + }; +} diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/deck/themes.ts b/examples/02-example/.agents/skills/deckforge/starter-components/deck/themes.ts new file mode 100644 index 0000000..2c109a8 --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/deck/themes.ts @@ -0,0 +1,256 @@ +import type { ThemeDef } from './types'; + +const cream: ThemeDef = { + id: 'editorial-cream', + name: 'Editorial Cream', + category: 'Editorial', + description: 'Magazine-like narrative presentation with cream background', + tokens: { + background: '#FAF3E7', + foreground: '#0F172A', + primary: '#2B2118', + secondary: '#B45309', + surface: '#F1EADF', + muted: '#64748B', + surfaceElevated: '#EAE3D8', + border: '#D7D1C7', + focus: '#B45309', + }, + typography: { headingFont: 'Libre Baskerville', bodyFont: 'Inter', codeFont: 'JetBrains Mono' }, + chartPalette: ['#2B2118', '#B45309', '#15803D', '#C2410C', '#EF4444', '#8B5CF6'], + shapeLanguage: 'soft', + motionStyle: 'cinematic', + scrollbar: { + default: 'minimal-thin', + grid: 'gradient-slim', + 'speaker-notes': 'gradient-slim', + presenter: 'none', + 'slide-stage': 'none', + }, + gradients: { + hero: 'radial-gradient(120% 120% at 80% 0%, #FBE9D2 0%, #FAF3E7 48%, #F4E7D5 100%)', + emphasis: 'linear-gradient(135deg, #F0E3D0 0%, #F6ECDC 100%)', + progress: 'linear-gradient(90deg, #B45309 0%, #D97706 100%)', + highlight: 'linear-gradient(180deg, transparent 62%, #F3D9B0 62%)', + accent: 'linear-gradient(135deg, #B45309 0%, #D97706 100%)', + }, +}; + +const oceanic: ThemeDef = { + id: 'oceanic-blueprint', + name: 'Oceanic Blueprint', + category: 'Architecture', + description: 'Blueprint grid over ocean blues', + tokens: { + background: '#FFFFFF', + foreground: '#0F172A', + primary: '#111827', + secondary: '#06B6D4', + surface: '#F6F6F6', + muted: '#64748B', + surfaceElevated: '#EEEEEE', + border: '#DBDBDB', + focus: '#06B6D4', + }, + typography: { headingFont: 'Manrope', bodyFont: 'Inter', codeFont: 'JetBrains Mono' }, + chartPalette: ['#111827', '#0891B2', '#15803D', '#D97706', '#EF4444', '#8B5CF6'], + shapeLanguage: 'technical', + motionStyle: 'precise', + scrollbar: { + default: 'gradient-slim', + 'slide-list': 'minimal-thin', + presenter: 'none', + 'slide-stage': 'none', + }, + gradients: { + hero: 'radial-gradient(120% 120% at 70% 0%, #E0F7FA 0%, #FFFFFF 55%, #F0FBFC 100%)', + emphasis: 'linear-gradient(135deg, #E8F8FA 0%, #F7FEFF 100%)', + progress: 'linear-gradient(90deg, #0891B2 0%, #06B6D4 100%)', + highlight: 'linear-gradient(180deg, transparent 62%, #C9F2F7 62%)', + accent: 'linear-gradient(135deg, #0891B2 0%, #06B6D4 100%)', + }, +}; + +const research: ThemeDef = { + id: 'research-lab', + name: 'Research Lab', + category: 'Research', + description: 'Academic but modern research slides with precise grids', + tokens: { + background: '#F8FAFC', + foreground: '#0F172A', + primary: '#0F172A', + secondary: '#0EA5E9', + surface: '#EFF1F3', + muted: '#64748B', + surfaceElevated: '#E8EAEC', + border: '#D5D7D9', + focus: '#0EA5E9', + }, + typography: { headingFont: 'IBM Plex Sans', bodyFont: 'Inter', codeFont: 'JetBrains Mono' }, + chartPalette: ['#0F172A', '#0284C7', '#15803D', '#D97706', '#EF4444', '#8B5CF6'], + shapeLanguage: 'soft', + motionStyle: 'cinematic', + scrollbar: { + default: 'minimal-thin', + 'speaker-notes': 'gradient-slim', + presenter: 'none', + 'slide-stage': 'none', + }, + gradients: { + hero: 'radial-gradient(120% 120% at 75% 0%, #E0F2FE 0%, #F8FAFC 55%, #EDF5FC 100%)', + emphasis: 'linear-gradient(135deg, #EAF3FB 0%, #F6FAFD 100%)', + progress: 'linear-gradient(90deg, #0284C7 0%, #0EA5E9 100%)', + highlight: 'linear-gradient(180deg, transparent 62%, #CFE7F8 62%)', + accent: 'linear-gradient(135deg, #0284C7 0%, #0EA5E9 100%)', + }, +}; + +const warm: ThemeDef = { + id: 'warm-product', + name: 'Warm Product', + category: 'Product', + description: 'Soft warm SaaS product storytelling', + tokens: { + background: '#FFF7ED', + foreground: '#0F172A', + primary: '#1F2937', + secondary: '#F97316', + surface: '#F6EEE5', + muted: '#64748B', + surfaceElevated: '#EEE7DE', + border: '#DBD4CC', + focus: '#F97316', + }, + typography: { headingFont: 'Manrope', bodyFont: 'Inter', codeFont: 'JetBrains Mono' }, + chartPalette: ['#1F2937', '#C2410C', '#15803D', '#D97706', '#EF4444', '#8B5CF6'], + shapeLanguage: 'sharp', + motionStyle: 'subtle', + scrollbar: { + default: 'gradient-slim', + presenter: 'none', + 'slide-stage': 'none', + }, + gradients: { + hero: 'radial-gradient(120% 120% at 70% 0%, #FFEDD5 0%, #FFF7ED 55%, #FFF0E0 100%)', + emphasis: 'linear-gradient(135deg, #FDEAD7 0%, #FFF5EC 100%)', + progress: 'linear-gradient(90deg, #EA580C 0%, #F97316 100%)', + highlight: 'linear-gradient(180deg, transparent 62%, #FFDFC2 62%)', + accent: 'linear-gradient(135deg, #EA580C 0%, #F97316 100%)', + }, +}; + +const carbon: ThemeDef = { + id: 'carbon-command', + name: 'Carbon Command', + category: 'Engineering', + description: 'Carbon-black command center for deep technical demos', + tokens: { + background: '#0A0A0A', + foreground: '#F8FAFC', + primary: '#84CC16', + secondary: '#38BDF8', + surface: '#222222', + muted: '#A7B0C0', + surfaceElevated: '#343434', + border: '#474747', + focus: '#38BDF8', + }, + typography: { headingFont: 'JetBrains Mono', bodyFont: 'Inter', codeFont: 'JetBrains Mono' }, + chartPalette: ['#84CC16', '#38BDF8', '#22C55E', '#F59E0B', '#EF4444', '#8B5CF6'], + shapeLanguage: 'technical', + motionStyle: 'precise', + scrollbar: { + default: 'gradient-slim', + 'slide-list': 'minimal-thin', + presenter: 'none', + 'slide-stage': 'none', + }, + gradients: { + hero: 'radial-gradient(120% 120% at 75% 0%, #1E293B 0%, #0A0A0A 55%, #111827 100%)', + emphasis: 'linear-gradient(135deg, #27272A 0%, #1A1A1A 100%)', + progress: 'linear-gradient(90deg, #65A30D 0%, #84CC16 100%)', + highlight: 'linear-gradient(180deg, transparent 62%, #2E3B2E 62%)', + accent: 'linear-gradient(135deg, #65A30D 0%, #84CC16 100%)', + }, +}; + +const monoInk: ThemeDef = { + id: 'mono-ink', + name: 'Mono Ink', + category: 'Minimal', + description: 'Black-and-white consultant elegance', + tokens: { + background: '#FAFAFA', + foreground: '#0F172A', + primary: '#18181B', + secondary: '#71717A', + surface: '#F1F1F1', + muted: '#64748B', + surfaceElevated: '#EAEAEA', + border: '#D7D7D7', + focus: '#71717A', + }, + typography: { headingFont: 'Sora', bodyFont: 'Inter', codeFont: 'JetBrains Mono' }, + chartPalette: ['#18181B', '#71717A', '#15803D', '#D97706', '#EF4444', '#8B5CF6'], + shapeLanguage: 'editorial', + motionStyle: 'snappy', + scrollbar: { + default: 'mono-ink', + presenter: 'none', + 'slide-stage': 'none', + }, + gradients: { + hero: 'radial-gradient(120% 120% at 80% 0%, #F4F4F5 0%, #FAFAFA 55%, #F0F0F0 100%)', + emphasis: 'linear-gradient(135deg, #ECECEC 0%, #F7F7F7 100%)', + progress: 'linear-gradient(90deg, #3F3F46 0%, #71717A 100%)', + highlight: 'linear-gradient(180deg, transparent 62%, #DDDDE1 62%)', + accent: 'linear-gradient(135deg, #3F3F46 0%, #71717A 100%)', + }, +}; + +const greenfield: ThemeDef = { + id: 'greenfield-growth', + name: 'Greenfield Growth', + category: 'Climate', + description: 'Green innovation and sustainability', + tokens: { + background: '#022C22', + foreground: '#F8FAFC', + primary: '#34D399', + secondary: '#A7F3D0', + surface: '#1B4138', + muted: '#A7B0C0', + surfaceElevated: '#2D5048', + border: '#416159', + focus: '#A7F3D0', + }, + typography: { headingFont: 'IBM Plex Sans', bodyFont: 'Inter', codeFont: 'JetBrains Mono' }, + chartPalette: ['#34D399', '#A7F3D0', '#22C55E', '#F59E0B', '#EF4444', '#8B5CF6'], + shapeLanguage: 'soft', + motionStyle: 'cinematic', + scrollbar: { + default: 'gradient-slim', + presenter: 'none', + 'slide-stage': 'none', + }, + gradients: { + hero: 'radial-gradient(120% 120% at 75% 0%, #0B3B2E 0%, #022C22 55%, #063528 100%)', + emphasis: 'linear-gradient(135deg, #1B4138 0%, #12342C 100%)', + progress: 'linear-gradient(90deg, #10B981 0%, #34D399 100%)', + highlight: 'linear-gradient(180deg, transparent 62%, #1E4A3D 62%)', + accent: 'linear-gradient(135deg, #10B981 0%, #34D399 100%)', + }, +}; + +const THEMES: ThemeDef[] = [cream, oceanic, research, warm, carbon, monoInk, greenfield]; + +const THEME_INDEX = new Map<string, ThemeDef>(THEMES.map((theme) => [theme.id, theme])); + +export function getTheme(id: string): ThemeDef { + return THEME_INDEX.get(id) ?? cream; +} + +export function listThemes(): ThemeDef[] { + return THEMES; +} diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/deck/types.ts b/examples/02-example/.agents/skills/deckforge/starter-components/deck/types.ts new file mode 100644 index 0000000..0bb3253 --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/deck/types.ts @@ -0,0 +1,319 @@ +import type { ReactNode } from 'react'; +import type { ScrollbarThemeMapping } from './scrollbars/scrollbarTypes'; + +export type PositionMode = 'slot' | 'flow' | 'freeform' | 'background'; +export type FitPolicy = 'wrap' | 'contain' | 'cover' | 'scroll' | 'change-layout' | 'split-slide'; + +export interface Frame { + x: number; + y: number; + w: number; + h: number; + rotation?: number; + z?: number; +} + +export interface BlockAnimation { + id: string; + trigger?: 'on-enter' | 'on-click' | 'with-previous' | 'after-previous'; + order?: number; + durationMs?: number; + delayMs?: number; + easing?: string; + reducedMotionFallback?: string; +} + +export interface BlockStyle { + variant?: string; + level?: number; + align?: string; + [key: string]: unknown; +} + +export interface ChartValue { + label: string; + value: number; +} + +export interface ChartContent { + type: 'bar' | 'bar-horizontal' | 'line'; + title?: string; + unit?: string; + values: ChartValue[]; + highlightIndex?: number; + summary?: string; + /** True when the chart is still a starter "New chart" template from the + * editor's block palette, not real authored content. Template charts are + * excluded from export rather than leaking placeholder data. */ + isTemplate?: boolean; +} + +export type AssetKind = 'image' | 'video' | 'audio' | 'font' | 'data' | 'document' | 'model' | 'embed-poster'; + +/** Asset manifest entry (plan §9.1 AssetManifestItem). */ +export interface DeckAsset { + id: string; + kind: AssetKind; + src: string; + mimeType?: string; + width?: number; + height?: number; + durationMs?: number; + alt?: string; + credit?: string; + license?: string; + integrity?: string; + posterSrc?: string; + transcriptSrc?: string; + focalPoint?: { x: number; y: number }; + status?: 'ready' | 'failed' | 'placeholder'; +} + +/** Content shape for image blocks (plan §9.2 ImageBlockData). */ +export interface ImageBlockContent { + assetId?: string; + src?: string; + fit?: 'cover' | 'contain'; + focalPoint?: { x: number; y: number }; + caption?: string; + attribution?: string; + alt?: string; + decorative?: boolean; + rounded?: boolean; +} + +export interface MetricContent { + value: string; + label?: string; + delta?: string; +} + +export interface ProcessStep { + title: string; + detail?: string; +} + +export interface Block { + id: string; + type: string; + content: unknown; + frame?: Frame; + style?: BlockStyle; + data?: Record<string, unknown>; + alt?: string; + ariaLabel?: string; + sourceIds?: string[]; + animation?: BlockAnimation; + locked?: boolean; + hidden?: boolean; + role?: string; + slot?: string; + positionMode?: PositionMode; + fitPolicy?: FitPolicy; + resolvedFrame?: Frame; + decorative?: boolean; + allowOverlap?: boolean; +} + +export interface LayoutBinding { + slot: string; + blockIds: string[]; + flow?: 'stack' | 'row' | 'grid' | 'overlay'; + gap?: number; +} + +export interface SlideInteraction { + id: string; + type: string; + trigger: string; + action: string; + payload?: unknown; + ariaLabel?: string; +} + +export interface DeckSlide { + id: string; + title: string; + layout: string; + hidden?: boolean; + section?: string; + background?: Record<string, unknown>; + blocks: Block[]; + speakerNotes?: string; + sources?: string[]; + interactions?: SlideInteraction[]; + transition?: string; + durationMs?: number; + tags?: string[]; + layoutVariant?: string; + layoutBindings?: LayoutBinding[]; + density?: 'low' | 'medium' | 'high'; + focalBlockId?: string; +} + +export interface SourceRef { + id: string; + title: string; + url: string; + authors?: string[]; + publisher?: string; + publishedAt?: string; + accessedAt?: string; + note?: string; + license?: string; +} + +export interface ThemeTokens { + background: string; + foreground: string; + primary: string; + secondary: string; + surface: string; + muted: string; + surfaceElevated: string; + border: string; + focus: string; +} + +/** + * Approved gradient uses (plan §10.3): hero backgrounds, small emphasis + * surfaces, progress bars, highlight sweeps, and decorative accents. + * Gradients must never cover body paragraphs, bullet lists, or data tables. + */ +export interface ThemeGradients { + hero?: string; + emphasis?: string; + progress?: string; + highlight?: string; + accent?: string; +} + +export interface ThemeDef { + id: string; + name: string; + category?: string; + description?: string; + tokens: ThemeTokens; + typography: { headingFont: string; bodyFont: string; codeFont: string }; + mood?: string; + chartPalette: string[]; + shapeLanguage?: string; + motionStyle?: string; + gradients?: ThemeGradients; + antiPatterns?: string[]; + scrollbar?: ScrollbarThemeMapping; +} + +export interface DeckProject { + schemaVersion: string; + meta: { + id: string; + slug: string; + title: string; + description?: string; + language: string; + audience?: string; + objective?: string; + templateId?: string; + authors?: string[]; + tags?: string[]; + createdAt?: string; + updatedAt?: string; + }; + canvas: { + aspectRatio: '16:9' | '4:3' | 'custom'; + width: number; + height: number; + safeMargin: number; + grid?: number; + responsiveMode?: 'letterbox' | 'reflow' | 'hybrid'; + layoutMode?: 'semantic-slots' | 'hybrid' | 'freeform'; + background?: string; + }; + theme: { id: string; overrides?: Record<string, unknown>; designSystemRef?: string; mode?: string }; + presentation: { + mode?: string; + transition?: string; + motionProfileId?: string; + defaultBuilds?: boolean; + keyboard?: boolean; + touch?: boolean; + deepLinks?: boolean; + overview?: boolean; + speakerView?: boolean; + progress?: boolean; + controls?: boolean; + reducedMotion?: 'respect-system' | 'always' | 'never'; + autoplay?: { enabled: boolean; intervalMs?: number; loop?: boolean; pauseOnInteraction?: boolean }; + }; + editor: { + enabled: boolean; + toolbar?: boolean; + history?: boolean; + snapToGrid?: boolean; + guides?: boolean; + comments?: boolean; + collaboration?: boolean; + autosave?: boolean; + commandPalette?: boolean; + notes?: boolean; + allowedBlockTypes?: string[]; + sidePanel?: boolean; + assetLibrary?: boolean; + themePicker?: boolean; + layoutPicker?: boolean; + shortcutHelp?: boolean; + saveStatus?: boolean; + persistence?: 'none' | 'local-storage' | 'api' | 'host-managed'; + routes?: Record<string, string>; + requiredZones?: string[]; + }; + assets?: DeckAsset[]; + slides: DeckSlide[]; + sources?: SourceRef[]; + publish?: { + visibility?: string; + slug?: string; + embed?: { enabled: boolean; allowedOrigins?: string[]; sandbox?: string[]; responsive?: boolean }; + analytics?: boolean; + allowDownload?: boolean; + }; + experience?: { + profile: string; + surfaces: string[]; + routes?: Record<string, string>; + capabilities?: string[]; + }; + shortcuts?: { + helpEnabled?: boolean; + helpKey?: string; + editorPreset?: string; + presenterPreset?: string; + overrides?: Record<string, unknown>; + }; +} + +export type SaveState = 'clean' | 'dirty' | 'saving' | 'saved' | 'failed' | 'offline' | 'conflict'; + +export interface EditorSelection { + slideId: string; + blockIds: string[]; + mode: 'block' | 'slide' | 'none'; +} + +export type Route = 'editor' | 'present'; + +export interface PresenterBuildState { + slideIndex: number; + step: number; +} + +export type RenderBlockProps = { + block: Block; + deck: DeckProject; + slide: DeckSlide; + editing?: boolean; + selected?: boolean; + onSelect?: (id: string, additive: boolean) => void; + renderNode?: (block: Block) => ReactNode; +}; diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/export-dialog.tsx b/examples/02-example/.agents/skills/deckforge/starter-components/export/export-dialog.tsx index 3e7eb35..d271b98 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/export-dialog.tsx +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/export-dialog.tsx @@ -3,10 +3,15 @@ import type { ExportPreflightResult, ExportReport, PptxExportConfig, + PreflightGroupSummary, } from "./export-types"; import { DEFAULT_PPTX_CONFIG } from "./export-types"; import { runExportPreflight } from "./export-preflight"; -import type { DeckProject } from "../deck-types"; +import { prepareExport, type PreparedExport } from "./prepare-export"; +import type { DeckProject, SaveState } from "../deck/types"; +import { makeDeckSelfContained } from "./self-contained"; +import { canonicalAssetRef } from "../deck/assets"; +import type { Command, DispatchResult } from "../deck/commands"; interface ExportDialogProps { deck: DeckProject; @@ -14,8 +19,33 @@ interface ExportDialogProps { onClose: () => void; onExport?: (result: Blob) => void; onError?: (error: Error) => void; + commit?: (command: Command) => DispatchResult | undefined; + saveNow?: (deck: DeckProject) => SaveState; } +/** + * Export dialog state machine (regression fix P2-003). + * + * The previous implementation kept a free-form `phase` alongside a heuristic + * score, so the UI could show "Ready to export" (from a geometry-unaware + * preflight) at the same time as "Export failed" (from the last real export), + * and repeated "Export failed" text when both the status line and the report + * box rendered. This version uses explicit, mutually exclusive states: + * + * IDLE → PREFLIGHTING → READY ─┐ + * │ ├─→ EXPORTING → SUCCESS + * ├→ BLOCKED └──────────────┘ + * └→ FAILED ←──────────────────────┘ + * + * READY is only reachable when preflight passes (no error issues and zero + * missing geometry); a failed preflight lands in BLOCKED (the export button + * is disabled), and a serialization failure lands in FAILED — the two are + * distinct states so "Export blocked" never shows the "FAILED" badge and the + * contradictory messages can never coexist. + */ +type ExportUiState = "idle" | "preflight" | "blocked" | "ready" | "exporting" | "success" | "failed"; +type ExportStage = "building" | "writing"; + function fidelitySummary(report: ExportReport): string { const fallbacks = report.slides.reduce( (total, slide) => @@ -35,34 +65,137 @@ function fidelitySummary(report: ExportReport): string { return `Native ${native} · Fallbacks ${fallbacks} · Missing ${missing}`; } -export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: ExportDialogProps) { +function currentImageSource(deck: DeckProject, slideId: string, blockId: string): string { + const slide = deck.slides.find((s) => s.id === slideId); + const block = slide?.blocks.find((b) => b.id === blockId); + if (!block) return ""; + return canonicalAssetRef(deck, block)?.src ?? ""; +} + +function fileToDataUri(file: File): Promise<string> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(reader.error ?? new Error("Could not read file")); + reader.readAsDataURL(file); + }); +} + +export function ExportDialog({ deck, isOpen, onClose, onExport, onError, commit, saveNow }: ExportDialogProps) { const [config, setConfig] = useState<PptxExportConfig>(DEFAULT_PPTX_CONFIG); const [preflight, setPreflight] = useState<ExportPreflightResult | null>(null); const [lastReport, setLastReport] = useState<ExportReport | null>(null); - const [isExporting, setIsExporting] = useState(false); + const [state, setState] = useState<ExportUiState>("idle"); + const [stage, setStage] = useState<ExportStage>("building"); + const [errorMessage, setErrorMessage] = useState<string>(""); + const [progress, setProgress] = useState(0); const [showDetails, setShowDetails] = useState(false); + const [fixDrafts, setFixDrafts] = useState<Record<string, string>>({}); + const [selfContaining, setSelfContaining] = useState(false); const dialogRef = useRef<HTMLDivElement>(null); const closeButtonRef = useRef<HTMLButtonElement>(null); + /** + * The single prepared export for the current deck+config. Preflight and the + * PPTX exporter MUST consume the SAME prepared result so "Ready to export" + * can never diverge from what the exporter will actually produce. Recreated + * whenever the deck or config changes. + */ + const preparedRef = useRef<PreparedExport | null>(null); const runPreflight = useCallback(async () => { if (!deck) return; - const result = await runExportPreflight(deck, config); - setPreflight(result); + setState("preflight"); + setErrorMessage(""); + try { + const prepared = await prepareExport(deck, config); + preparedRef.current = prepared; + const result = await runExportPreflight(prepared); + setPreflight(result); + setState(result.ready ? "ready" : "blocked"); + if (!result.ready) { + const errors = result.issues.filter((issue) => issue.severity === "error"); + setErrorMessage( + errors.length > 0 + ? `Preflight found ${errors.length} issue(s) that block a lossless export. Resolve them before exporting.` + : "Preflight found content that cannot be preserved. Resolve it before exporting.", + ); + } + } catch (error) { + setState("failed"); + setErrorMessage( + `Preflight analysis failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } }, [deck, config]); + const applyImageFix = (issue: { slideId?: string; blockId?: string }) => { + if (!issue.slideId || !issue.blockId || !commit) return; + const src = (fixDrafts[issue.blockId] ?? currentImageSource(deck, issue.slideId, issue.blockId)).trim(); + if (!src) return; + commit({ type: "updateImageSource", slideId: issue.slideId, blockId: issue.blockId, src }); + }; + + const chooseFileFix = async (issue: { slideId?: string; blockId?: string }) => { + if (!issue.slideId || !issue.blockId || !commit) return; + const input = document.getElementById(`fix-file-${issue.blockId}`) as HTMLInputElement | null; + const file = input?.files?.[0]; + if (!file) return; + const uri = await fileToDataUri(file); + setFixDrafts((d) => ({ ...d, [issue.blockId!]: uri })); + commit({ type: "updateImageSource", slideId: issue.slideId, blockId: issue.blockId, src: uri }); + }; + + const handleSelfContained = async () => { + if (!deck || !commit || selfContaining) return; + setSelfContaining(true); + setErrorMessage(""); + try { + const result = await makeDeckSelfContained(deck); + commit({ type: "replaceDeck", deck: result.deck }); + saveNow?.(result.deck); + if (result.failures.length > 0) { + setErrorMessage( + `${result.failures.length} image(s) could not be embedded offline. ` + + "Resolve the remaining issues to export.", + ); + } + } catch (error) { + setErrorMessage( + `Could not make the deck self-contained: ${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + setSelfContaining(false); + } + }; + useEffect(() => { if (isOpen) { + setLastReport(null); + setErrorMessage(""); + setProgress(0); + setState("preflight"); runPreflight(); closeButtonRef.current?.focus(); } }, [isOpen, runPreflight]); + // Re-run preflight whenever the configuration changes so "Ready to export" + // always reflects the actual config (e.g. speaker notes toggled on/off). + useEffect(() => { + if (isOpen && state !== "exporting") { + runPreflight(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config]); + useEffect(() => { if (!isOpen) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { + if (state === "exporting") return; onClose(); + return; } if (e.key === "Tab" && dialogRef.current) { @@ -84,26 +217,40 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [isOpen, onClose]); + }, [isOpen, onClose, state]); const handleExport = async () => { if (!deck) return; - setIsExporting(true); + setState("exporting"); + setStage("building"); + setErrorMessage(""); + setProgress(10); try { const { PptxExporter } = await import("./pptx/pptx-exporter"); + setProgress(30); const exporter = new PptxExporter(config); - const result = await exporter.export(deck); + setStage("writing"); + setProgress(50); + // Reuse the SAME prepared export that preflight consumed — the exporter + // must never re-resolve assets or it could disagree with the READY + // verdict (regression: preflight "Ready" + export "Failed to resolve"). + const prepared = preparedRef.current ?? (await prepareExport(deck, config)); + preparedRef.current = prepared; + const result = await exporter.export(prepared); + setProgress(90); setLastReport(result.report); if (result.report.status === "failed") { - onError?.( - new Error( - "Export failed: content could not be fully preserved. Fix the missing content before downloading.", - ), + setState("failed"); + setErrorMessage( + "Export failed: content could not be fully preserved. Fix the missing content before downloading.", ); + onError?.(new Error(errorMessage)); return; } + setProgress(100); + setState("success"); onExport?.(result.blob); const deckData = deck as { meta?: { title?: string } }; @@ -117,17 +264,21 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor const a = document.createElement("a"); a.href = url; a.download = filename; + document.body.appendChild(a); a.click(); + document.body.removeChild(a); URL.revokeObjectURL(url); } catch (error) { + setState("failed"); + const message = error instanceof Error ? error.message : String(error); + setErrorMessage(`Export failed: ${message}`); onError?.(error as Error); - } finally { - setIsExporting(false); } }; if (!isOpen) return null; + const isExporting = state === "exporting"; const scoreColor = (preflight?.score ?? 0) >= 80 ? "var(--theme-secondary, #10b981)" @@ -135,12 +286,34 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor ? "#f59e0b" : "var(--ui-danger, #dc2626)"; - const reportColor = - lastReport?.status === "complete" - ? "var(--theme-secondary, #10b981)" - : lastReport?.status === "partial" - ? "#f59e0b" - : "var(--ui-danger, #dc2626)"; + const canExport = (state === "ready" || state === "success") && !isExporting; + + const FIXABLE_IMAGE_CODES = new Set(["unresolved-image", "image-load-failed", "unknown-asset"]); + const fixableIssues = + preflight?.issues.filter( + (i) => FIXABLE_IMAGE_CODES.has(i.code) && Boolean(i.slideId) && Boolean(i.blockId), + ) ?? []; + + const stageLabel: Record<ExportStage, string> = { + building: "Building slides...", + writing: "Writing PPTX...", + }; + + const renderIssues = (issues: Array<{ severity: string; message: string; suggestedFix?: string }>) => ( + <div style={{ fontSize: 12, color: "var(--ui-muted)", maxHeight: 140, overflowY: "auto" }}> + {issues.slice(0, 8).map((issue, idx) => ( + <div key={idx} style={{ marginBottom: 3 }}> + <span style={{ color: issue.severity === "error" ? "var(--ui-danger)" : issue.severity === "warning" ? "#b45309" : "var(--ui-muted)", fontWeight: 600 }}> + {issue.severity} + </span>{" "} + {issue.message} + </div> + ))} + {issues.length > 8 && ( + <div style={{ marginTop: 4 }}>… {issues.length - 8} more issue(s)</div> + )} + </div> + ); return ( <div @@ -148,7 +321,7 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor role="dialog" aria-modal="true" aria-labelledby="export-dialog-title" - onClick={(e) => { if (e.target === e.currentTarget) onClose(); }} + onClick={(e) => { if (e.target === e.currentTarget && !isExporting) onClose(); }} > <div className="dialog" ref={dialogRef} style={{ maxWidth: 480 }}> <div className="dialog-header"> @@ -158,6 +331,7 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor className="icon-button" onClick={onClose} aria-label="Close export dialog" + disabled={isExporting} > × </button> @@ -169,13 +343,50 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor <select value={config.mode} onChange={(e) => setConfig({ ...config, mode: e.target.value as PptxExportConfig["mode"] })} + disabled={isExporting} > <option value="fidelity-first">PPTX (Fidelity First)</option> <option value="editability-first">PPTX (Editability First)</option> </select> </label> - {preflight && ( + {state === "preflight" && ( + <div role="status" aria-live="polite" style={{ marginBottom: 14 }}> + <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}> + <span className="spinner" aria-hidden="true" style={{ + width: 14, height: 14, border: "2px solid var(--ui-border)", + borderTopColor: "var(--ui-fg)", borderRadius: "50%", + display: "inline-block", animation: "spin 0.7s linear infinite", + }} /> + <span style={{ fontWeight: 600, fontSize: 13, color: "var(--ui-fg)" }}> + Analyzing deck... + </span> + </div> + </div> + )} + + {isExporting && ( + <div role="status" aria-live="polite" style={{ marginBottom: 14 }}> + <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}> + <span className="spinner" aria-hidden="true" style={{ + width: 14, height: 14, border: "2px solid var(--ui-border)", + borderTopColor: "var(--ui-fg)", borderRadius: "50%", + display: "inline-block", animation: "spin 0.7s linear infinite", + }} /> + <span style={{ fontWeight: 600, fontSize: 13, color: "var(--ui-fg)" }}> + {stageLabel[stage]} + </span> + </div> + <div style={{ height: 4, borderRadius: 2, background: "var(--ui-border)", overflow: "hidden" }}> + <div style={{ + height: "100%", width: `${progress}%`, borderRadius: 2, + background: "var(--ui-fg)", transition: "width 0.3s ease", + }} /> + </div> + </div> + )} + + {state === "ready" && preflight && ( <div role="status" aria-live="polite" @@ -189,66 +400,160 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor > <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 6 }}> <span style={{ fontWeight: 600, fontSize: 13, color: "var(--ui-fg)" }}> - {(preflight.score ?? 0) >= 80 ? "Ready to export" : (preflight.score ?? 0) >= 50 ? "Export with warnings" : "Issues detected"} + Ready to export </span> <span style={{ fontFamily: "var(--font-code)", fontSize: 12, color: scoreColor, fontWeight: 600 }}> {preflight.score}/100 </span> </div> - <div style={{ display: "flex", gap: 12, fontSize: 12, color: "var(--ui-muted)" }}> + <div style={{ display: "flex", gap: 12, fontSize: 12, color: "var(--ui-muted)", flexWrap: "wrap" }}> <span>Coverage {Math.round(preflight.blockCoverage * 100)}%</span> <span>Recall {Math.round((preflight.estimatedRecall ?? 1) * 100)}%</span> - <span>{preflight.estimatedFallbacks ?? 0} fallbacks</span> - <span>{(preflight.estimatedMissing ?? 0) > 0 ? `${preflight.estimatedMissing} missing` : "0 missing"}</span> - <span>{preflight.issues.filter(i => i.severity === "warning").length} warnings</span> - <span>{preflight.issues.filter(i => i.severity === "info").length} info</span> + <span>Native {preflight.coverage.native}</span> + <span>Fallbacks {preflight.coverage.fallback}</span> + <span>Missing {preflight.coverage.missing}</span> + {preflight.coverage.satisfied && ( + <span style={{ color: "var(--theme-secondary, #10b981)", fontWeight: 600 }}> + invariants OK + </span> + )} </div> </div> )} - {lastReport && ( + {(state === "failed" || state === "blocked") && ( <div - role="status" + role="alert" aria-live="polite" style={{ padding: "12px 14px", borderRadius: "var(--ui-radius)", - border: `1px solid ${reportColor}33`, - backgroundColor: `${reportColor}0A`, + border: `1px solid ${state === "failed" ? "var(--ui-danger, #dc2626)" : "var(--ui-danger, #dc2626)"}33`, + backgroundColor: "var(--ui-danger, #dc2626)0A", marginBottom: 14, }} > <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 6 }}> + <span style={{ fontWeight: 600, fontSize: 13, color: "var(--ui-danger, #dc2626)" }}> + {state === "failed" ? "Export failed" : "Export blocked"} + </span> + <span style={{ fontFamily: "var(--font-code)", fontSize: 12, color: "var(--ui-danger, #dc2626)", fontWeight: 600, textTransform: "uppercase" }}> + {state === "failed" ? "FAILED" : "BLOCKED"} + </span> + </div> + <div style={{ fontSize: 12, color: "var(--ui-muted)", marginBottom: 4 }}> + {errorMessage} + </div> + </div> + )} + + {state === "blocked" && fixableIssues.length > 0 && ( + <div style={{ marginBottom: 14 }}> + {fixableIssues.map((issue) => { + const blockId = issue.blockId!; + const value = fixDrafts[blockId] ?? currentImageSource(deck, issue.slideId!, blockId); + return ( + <div + key={`${issue.slideId}:${blockId}`} + style={{ + display: "flex", + flexDirection: "column", + gap: 6, + padding: "10px 12px", + borderRadius: "var(--ui-radius)", + border: "1px solid var(--ui-border)", + marginBottom: 8, + background: "var(--ui-surface)", + }} + > + <div style={{ fontSize: 12, fontWeight: 600, color: "var(--ui-fg)" }}> + Fix image {blockId} + </div> + <input + type="text" + aria-label={`Image source for ${blockId}`} + value={value} + onChange={(e) => setFixDrafts((d) => ({ ...d, [blockId]: e.target.value }))} + disabled={isExporting} + placeholder="https://… or data:image/…" + style={{ + padding: "6px 8px", + borderRadius: "var(--ui-radius)", + border: "1px solid var(--ui-border)", + background: "var(--ui-bg)", + fontSize: 12, + color: "var(--ui-fg)", + }} + /> + <div style={{ display: "flex", gap: 12, alignItems: "center" }}> + <label + htmlFor={`fix-file-${blockId}`} + style={{ fontSize: 12, cursor: "pointer", color: "var(--ui-fg)" }} + > + Choose file… + </label> + <input + id={`fix-file-${blockId}`} + type="file" + accept="image/*" + style={{ display: "none" }} + onChange={() => chooseFileFix(issue)} + /> + <button + onClick={() => applyImageFix(issue)} + aria-label={`Apply image fix for ${blockId}`} + disabled={isExporting} + style={{ + marginLeft: "auto", + padding: "5px 12px", + borderRadius: "var(--ui-radius)", + border: "none", + background: "var(--ui-fg)", + color: "#fff", + fontSize: 12, + fontWeight: 600, + cursor: isExporting ? "not-allowed" : "pointer", + }} + > + Apply + </button> + </div> + </div> + ); + })} + </div> + )} + + {state === "success" && lastReport && ( + <div + role="status" + aria-live="polite" + style={{ + padding: "12px 14px", + borderRadius: "var(--ui-radius)", + border: "1px solid var(--theme-secondary, #10b981)33", + backgroundColor: "var(--theme-secondary, #10b981)0A", + marginBottom: 14, + }} + > + <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}> + <span style={{ color: "var(--theme-secondary, #10b981)", fontSize: 16 }} aria-hidden="true">✓</span> <span style={{ fontWeight: 600, fontSize: 13, color: "var(--ui-fg)" }}> - Export {lastReport.status} + Export complete! </span> - <span style={{ fontFamily: "var(--font-code)", fontSize: 12, color: reportColor, fontWeight: 600, textTransform: "uppercase" }}> + <span style={{ fontFamily: "var(--font-code)", fontSize: 12, color: "var(--theme-secondary, #10b981)", fontWeight: 600, textTransform: "uppercase", marginLeft: "auto" }}> {lastReport.status} </span> </div> <div style={{ fontSize: 12, color: "var(--ui-muted)", marginBottom: 4 }}> {fidelitySummary(lastReport)} </div> - {lastReport.status === "failed" && ( - <div style={{ marginTop: 8, fontSize: 12, color: "var(--ui-danger, #dc2626)", fontWeight: 600 }}> - Export blocked: content was not fully preserved. Review the missing blocks below. - </div> - )} - {lastReport.issues.length > 0 && ( - <div style={{ fontSize: 12, color: "var(--ui-muted)", maxHeight: 120, overflowY: "auto" }}> - {lastReport.issues.slice(0, 8).map((issue, idx) => ( - <div key={idx} style={{ marginBottom: 3 }}> - <span style={{ color: issue.severity === "error" ? "var(--ui-danger)" : issue.severity === "warning" ? "#b45309" : "var(--ui-muted)", fontWeight: 600 }}> - {issue.severity} - </span>{" "} - {issue.message} - </div> - ))} - {lastReport.issues.length > 8 && ( - <div style={{ marginTop: 4 }}>… {lastReport.issues.length - 8} more issue(s)</div> - )} - </div> - )} + </div> + )} + + {lastReport && lastReport.issues.length > 0 && state !== "exporting" && ( + <div style={{ fontSize: 12, color: "var(--ui-muted)", marginBottom: 14 }}> + {renderIssues(lastReport.issues)} </div> )} @@ -257,21 +562,32 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor type="checkbox" checked={config.includeSpeakerNotes} onChange={(e) => setConfig({ ...config, includeSpeakerNotes: e.target.checked })} + disabled={isExporting} /> Include speaker notes </label> <div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}> + <button + className="text-button" + onClick={handleSelfContained} + disabled={isExporting || selfContaining} + style={{ fontSize: 12, marginRight: "auto" }} + > + {selfContaining ? "Embedding images…" : "Make deck self-contained"} + </button> <button className="text-button" onClick={() => setShowDetails(!showDetails)} aria-expanded={showDetails} style={{ fontSize: 12 }} + disabled={isExporting || !preflight} > {showDetails ? "Hide details" : "View details"} </button> <button onClick={onClose} + disabled={isExporting} style={{ padding: "6px 14px", borderRadius: "var(--ui-radius)", @@ -281,30 +597,29 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor fontWeight: 500, }} > - Cancel + {state === "success" ? "Close" : "Cancel"} </button> <button onClick={handleExport} - disabled={isExporting || (preflight?.score ?? 0) < 20 || lastReport?.status === "failed"} + disabled={!canExport} aria-busy={isExporting} style={{ padding: "6px 14px", borderRadius: "var(--ui-radius)", border: "none", - background: "var(--ui-fg)", - color: "#fff", + background: canExport ? "var(--ui-fg)" : "var(--ui-border)", + color: canExport ? "#fff" : "var(--ui-muted)", fontSize: 13, fontWeight: 600, - cursor: isExporting ? "not-allowed" : "pointer", - opacity: isExporting || (preflight?.score ?? 0) < 20 ? 0.5 : 1, + cursor: canExport ? "pointer" : "not-allowed", }} > - {isExporting ? "Exporting..." : "Export PPTX"} + {isExporting ? "Exporting..." : state === "success" ? "Export Again" : "Export PPTX"} </button> </div> </div> - {showDetails && preflight && ( + {showDetails && preflight && state !== "exporting" && ( <div style={{ borderTop: "1px solid var(--ui-border)", padding: "14px 18px" }}> <h3 style={{ fontSize: 12, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em", color: "var(--ui-muted)", margin: "0 0 10px" }}> Preflight Issues @@ -312,31 +627,43 @@ export function ExportDialog({ deck, isOpen, onClose, onExport, onError }: Expor {preflight.issues.length === 0 ? ( <p style={{ fontSize: 13, color: "var(--ui-muted)", margin: 0 }}>No issues found</p> ) : ( - <ul style={{ listStyle: "none", padding: 0, margin: 0 }}> - {preflight.issues.map((issue: { severity: string; message: string; suggestedFix?: string }, idx: number) => ( - <li - key={idx} - style={{ - padding: "8px 10px", - marginBottom: 4, - borderRadius: "var(--ui-radius)", - backgroundColor: issue.severity === "error" ? "#fef2f2" : issue.severity === "warning" ? "#fffbeb" : "var(--ui-surface)", - fontSize: 12, - lineHeight: 1.5, - }} - > - <span style={{ fontWeight: 600, color: issue.severity === "error" ? "var(--ui-danger)" : issue.severity === "warning" ? "#b45309" : "var(--ui-muted)" }}> - {issue.severity} - </span>{" "} - {issue.message} - {issue.suggestedFix && ( - <div style={{ marginTop: 3, color: "var(--ui-muted)", fontSize: 11 }}> - {issue.suggestedFix} - </div> + preflight.groups.map((group: PreflightGroupSummary) => ( + <div key={group.group} style={{ marginBottom: 10 }}> + <div style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em", color: "var(--ui-muted)", margin: "0 0 6px" }}> + {group.label} <span style={{ fontWeight: 400 }}>({group.count})</span> + </div> + <ul style={{ listStyle: "none", padding: 0, margin: 0 }}> + {group.issues.slice(0, 5).map((issue, idx) => ( + <li + key={idx} + style={{ + padding: "8px 10px", + marginBottom: 4, + borderRadius: "var(--ui-radius)", + backgroundColor: issue.severity === "error" ? "#fef2f2" : issue.severity === "warning" ? "#fffbeb" : "var(--ui-surface)", + fontSize: 12, + lineHeight: 1.5, + }} + > + <span style={{ fontWeight: 600, color: issue.severity === "error" ? "var(--ui-danger)" : issue.severity === "warning" ? "#b45309" : "var(--ui-muted)" }}> + {issue.severity} + </span>{" "} + {issue.message} + {issue.suggestedFix && ( + <div style={{ marginTop: 3, color: "var(--ui-muted)", fontSize: 11 }}> + {issue.suggestedFix} + </div> + )} + </li> + ))} + {group.issues.length > 5 && ( + <li style={{ fontSize: 11, color: "var(--ui-muted)", paddingLeft: 10 }}> + … {group.issues.length - 5} more + </li> )} - </li> - ))} - </ul> + </ul> + </div> + )) )} </div> )} diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/export-preflight.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/export-preflight.ts index d4f88de..7f0213a 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/export-preflight.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/export-preflight.ts @@ -1,30 +1,33 @@ // export/export-preflight.ts +// +// Export preflight validation that ensures all blocks are properly resolved +// before export. This prevents: +// - Hidden/stale/template blocks from being exported +// - Missing chart data +// - Missing image assets +// - Geometry errors +// - Duplicate block exports +// +// Preflight operates on the output of the single `prepareExport` phase: it +// consumes the prepared snapshots and the canonical asset registry, so +// "Ready to export" is only ever reported when every required visible image +// actually resolved to embeddable bytes. No network work happens here. +import type { DeckProject } from "../deck/types"; import type { - ExportPreflightResult, ExportIssue, + ExportPreflightResult, + ExportCoverage, + PreflightGroupSummary, PptxExportConfig, } from "./export-types"; -import type { DeckProject } from "../deck-types"; -import { collectFontWarnings } from "./pptx/pptx-fonts"; +import { DEFAULT_PPTX_CONFIG } from "./export-types"; +import type { ImmutableSlideSnapshot, ResolvedBlockSnapshot } from "./snapshot"; +import { hashSlideSemanticContent } from "./snapshot"; +import { prepareExport, isPreparedExport, type PreparedExport } from "./prepare-export"; import { getBlockExporter } from "./pptx/block-exporters/index"; -const NATIVE_BLOCK_TYPES = new Set([ - "text", - "heading", - "bullets", - "callout", - "citation", - "metric", - "image", - "shape", - "table", - "chart", -]); - -function asRecord(value: unknown): Record<string, unknown> { - return value as Record<string, unknown>; -} +// ─── Preflight Scoring ─────────────────────────────────────────────────────── function calculateScore(issues: ExportIssue[]): number { let score = 100; @@ -36,134 +39,485 @@ function calculateScore(issues: ExportIssue[]): number { return Math.max(0, Math.min(100, score)); } -function calculateBlockCoverage(deck: DeckProject): number { - const blocks = deck.slides.flatMap((slide) => slide.blocks); - if (blocks.length === 0) return 1; +// ─── Preflight Validators ──────────────────────────────────────────────────── - const nativeCount = blocks.filter((block) => NATIVE_BLOCK_TYPES.has(block.type)).length; - return nativeCount / blocks.length; -} +/** + * Validate that a snapshot contains no hidden/stale/template blocks. + */ +function validateBlockVisibility( + block: ResolvedBlockSnapshot, + slideId: string +): ExportIssue[] { + const issues: ExportIssue[] = []; -function calculateParityEstimates(deck: DeckProject): { - estimatedRecall: number; - estimatedFallbacks: number; - estimatedMissing: number; -} { - const visible = deck.slides - .filter((slide) => !slide.hidden) - .flatMap((slide) => slide.blocks) - .filter((block) => !block.hidden); - if (visible.length === 0) { - return { estimatedRecall: 1, estimatedFallbacks: 0, estimatedMissing: 0 }; - } - - let fallbacks = 0; - let missing = 0; - for (const block of visible) { - const exporter = getBlockExporter(block.type); - if (exporter.type === "fallback" && block.type !== "fallback") { - missing += 1; - } else if (exporter.exportability === "image-only") { - fallbacks += 1; - } + if (block.visibility === "hidden") { + issues.push({ + code: "block-hidden-skipped", + severity: "warning", + slideId, + blockId: block.id, + message: `Block "${block.id}" is hidden but included in snapshot`, + automaticFixAvailable: true, + }); } - const preserved = visible.length - missing; - return { - estimatedRecall: preserved / visible.length, - estimatedFallbacks: fallbacks, - estimatedMissing: missing, - }; + + if (block.editorOnly) { + issues.push({ + code: "block-hidden-skipped", + severity: "warning", + slideId, + blockId: block.id, + message: `Block "${block.id}" is editor-only but included in snapshot`, + automaticFixAvailable: true, + }); + } + + if (block.deleted) { + issues.push({ + code: "block-hidden-skipped", + severity: "warning", + slideId, + blockId: block.id, + message: `Block "${block.id}" is deleted but included in snapshot`, + automaticFixAvailable: true, + }); + } + + if (block.temporary) { + issues.push({ + code: "block-hidden-skipped", + severity: "warning", + slideId, + blockId: block.id, + message: `Block "${block.id}" is temporary but included in snapshot`, + automaticFixAvailable: true, + }); + } + + if (block.placeholder) { + issues.push({ + code: "block-hidden-skipped", + severity: "warning", + slideId, + blockId: block.id, + message: `Block "${block.id}" is placeholder but included in snapshot`, + automaticFixAvailable: true, + }); + } + + return issues; } -export async function runExportPreflight( - deck: DeckProject, - config: PptxExportConfig -): Promise<ExportPreflightResult> { +/** + * Validate chart blocks have required data. + */ +function validateChartBlock( + block: ResolvedBlockSnapshot, + slideId: string +): ExportIssue[] { const issues: ExportIssue[] = []; - const fontWarnings = collectFontWarnings(deck); - for (const fw of fontWarnings) { + if (block.type !== "chart") return issues; + + if (!block.chartSpec) { issues.push({ + code: "chart-no-data", severity: "warning", - code: "font-substitution", - slideId: fw.slideId, - blockId: fw.blockId, - message: `Font "${fw.fontFamily}" may be substituted with ${fw.substituteFont}`, - suggestedFix: `Use a PPTX-safe font like ${fw.substituteFont}`, + slideId, + blockId: block.id, + message: `Chart block "${block.id}" has no resolved chart spec`, + suggestedFix: "Add data values to the chart", automaticFixAvailable: false, }); + return issues; } - for (const slide of deck.slides) { - for (const block of slide.blocks) { - const record = asRecord(block); - const blockType = block.type; + if (block.chartSpec.categories.length === 0) { + issues.push({ + code: "chart-no-data", + severity: "warning", + slideId, + blockId: block.id, + message: `Chart block "${block.id}" has no categories`, + suggestedFix: "Add category labels to the chart", + automaticFixAvailable: false, + }); + } - if (!NATIVE_BLOCK_TYPES.has(blockType)) { - issues.push({ - severity: "warning", - code: "unsupported-block-type", - slideId: slide.id, + if (block.chartSpec.series.length === 0 || block.chartSpec.series[0].values.length === 0) { + issues.push({ + code: "chart-no-data", + severity: "warning", + slideId, + blockId: block.id, + message: `Chart block "${block.id}" has no series data`, + suggestedFix: "Add data values to the chart", + automaticFixAvailable: false, + }); + } + + return issues; +} + +/** + * Classify an image block against the resolved asset registry and report the + * issues that block (or constrain) an export. + * + * ready → native (resolved to embeddable bytes in preparation) + * failed → Fidelity First: missing + blocking error + * Editability First: fallback + warning (placeholder embedded) + * no snapshot → placeholder block (no source): fallback + info + */ +function classifyImageBlock( + block: ResolvedBlockSnapshot, + slideId: string, + config: PptxExportConfig, +): { representation: "native" | "fallback" | "missing"; issues: ExportIssue[] } { + const as = block.assetSnapshot; + + if (!as) { + return { + representation: "fallback", + issues: [ + { + code: "image-load-failed", + severity: "info", + slideId, blockId: block.id, - message: `Block type "${blockType}" cannot be exported natively; it will be rasterized, substituted, or omitted`, - suggestedFix: "Convert to a supported block type for native export", - automaticFixAvailable: false, - }); - } + message: `Image block "${block.id}" has no image source; a bundled placeholder raster will be embedded`, + suggestedFix: "Attach a local asset to the image block or use a data: URL", + automaticFixAvailable: true, + }, + ], + }; + } - if (typeof record.cssFilter === "string" && record.cssFilter.includes("blur")) { - issues.push({ - severity: "warning", - code: "unsupported-css-effect", - slideId: slide.id, + if (as.status === "ready") { + return { representation: "native", issues: [] }; + } + + const reason = + as.error ?? + (as.resolvedSrc ? `image "${as.resolvedSrc}" could not be resolved` : "no resolvable source"); + + if (config.mode === "fidelity-first") { + return { + representation: "missing", + issues: [ + { + code: "unresolved-image", + severity: "error", + slideId, blockId: block.id, - message: "CSS filter effects may not transfer to PowerPoint", - suggestedFix: "Remove blur filter or accept image fallback", + message: `Image block "${block.id}" cannot be embedded in the PPTX: ${reason}`, + suggestedFix: "Fix the image URL or attach a local/data: asset so the image can be embedded offline", automaticFixAvailable: false, - }); + }, + ], + }; + } + + return { + representation: "fallback", + issues: [ + { + code: "image-load-failed", + severity: "warning", + slideId, + blockId: block.id, + message: `Image block "${block.id}" cannot be embedded: ${reason}; a bundled placeholder raster will be embedded in its place`, + suggestedFix: "Fix the image URL or attach a local/data: asset so the image can be embedded offline", + automaticFixAvailable: false, + }, + ], + }; +} + +/** + * Validate geometry for all blocks. + */ +function validateBlockGeometry( + block: ResolvedBlockSnapshot, + slideId: string +): ExportIssue[] { + const issues: ExportIssue[] = []; + + if (!block.frame) { + issues.push({ + code: "invalid-geometry", + severity: "error", + slideId, + blockId: block.id, + message: `Block "${block.id}" has no geometry`, + automaticFixAvailable: false, + }); + return issues; + } + + const { x, y, w, h } = block.frame; + if (w <= 0 || h <= 0) { + issues.push({ + code: "invalid-geometry", + severity: "error", + slideId, + blockId: block.id, + message: `Block "${block.id}" has invalid dimensions (${w}x${h})`, + automaticFixAvailable: false, + }); + } + + return issues; +} + +/** + * Validate no duplicate block IDs in a snapshot. + */ +function validateNoDuplicateBlockIds( + snapshot: ImmutableSlideSnapshot +): ExportIssue[] { + const issues: ExportIssue[] = []; + const seenIds = new Set<string>(); + + for (const block of snapshot.blocks) { + if (seenIds.has(block.id)) { + issues.push({ + code: "duplicate-element-id", + severity: "warning", + slideId: snapshot.slideId, + blockId: block.id, + message: `Block "${block.id}" is duplicated in slide "${snapshot.slideId}"`, + automaticFixAvailable: false, + }); + } + seenIds.add(block.id); + } + + return issues; +} + +// ─── Main Preflight Function ───────────────────────────────────────────────── + +/** + * Run export preflight validation on a prepared export. + * + * Pass the result of `prepareExport` so the preflight, fidelity accounting and + * the PPTX exporter all reason about the SAME resolved assets. For backward + * compatibility a raw `DeckProject` is prepared on the fly (this still + * resolves assets exactly once, inside that preparation). + */ +export async function runExportPreflight( + input: PreparedExport | DeckProject, + config?: PptxExportConfig +): Promise<ExportPreflightResult> { + const prepared: PreparedExport = isPreparedExport(input) + ? input + : await prepareExport(input, config ?? DEFAULT_PPTX_CONFIG); + + const issues: ExportIssue[] = []; + + let chartBlockCount = 0; + let geometryMissingCount = 0; + + // Parity/coverage tallies over all visible blocks (fractions per contract). + let visibleCount = 0; + let nativeCount = 0; + let fallbackCount = 0; + let missingCount = 0; + + for (const snapshot of prepared.slides) { + const rawSlide = prepared.deck.slides.find((slide) => slide.id === snapshot.slideId); + if (!rawSlide) continue; + + // Validate each block that made it into the canonical snapshot. + for (const block of snapshot.blocks) { + visibleCount++; + + if (block.type === "chart") chartBlockCount++; + + issues.push(...validateBlockVisibility(block, snapshot.slideId)); + issues.push(...validateChartBlock(block, snapshot.slideId)); + issues.push(...validateBlockGeometry(block, snapshot.slideId)); + + if (block.type === "image") { + const classification = classifyImageBlock(block, snapshot.slideId, prepared.config); + issues.push(...classification.issues); + if (classification.representation === "native") nativeCount++; + else if (classification.representation === "fallback") fallbackCount++; + else missingCount++; + continue; } - if (typeof record.src === "string" && record.src.startsWith("http") && !record.src.startsWith("data:")) { + const exporter = getBlockExporter(block.type); + if (exporter.type === "fallback" && block.type !== "fallback") { issues.push({ - severity: "info", - code: "external-asset", - slideId: slide.id, + code: "unsupported-block-type", + severity: "warning", + slideId: snapshot.slideId, blockId: block.id, - message: "External asset will be embedded in the export", - suggestedFix: undefined, + message: `Block type "${block.type}" cannot be exported natively; it will be rasterized, substituted, or omitted`, + suggestedFix: "Convert to a supported block type for native export", automaticFixAvailable: false, }); + missingCount++; + continue; + } + if (exporter.exportability === "image-only") { + fallbackCount++; + } else { + nativeCount++; } } - if (config.includeSpeakerNotes && !slide.speakerNotes) { + issues.push(...validateNoDuplicateBlockIds(snapshot)); + + // Fail closed on geometry: any visible raw block missing from the canonical + // snapshot has no resolvable frame and cannot be exported. + const snapshotBlockIds = new Set(snapshot.blocks.map((block) => block.id)); + for (const block of rawSlide.blocks) { + if (block.hidden) continue; + if (snapshotBlockIds.has(block.id)) continue; + geometryMissingCount++; + visibleCount++; issues.push({ - severity: "info", - code: "missing-speaker-notes", - slideId: slide.id, - message: "Slide has no speaker notes", - suggestedFix: "Add speaker notes for better presenter experience", - automaticFixAvailable: false, + code: "invalid-geometry", + severity: "error", + slideId: rawSlide.id, + blockId: block.id, + message: `Block "${block.id}" (${block.type}) has no resolvable frame and cannot be exported`, + suggestedFix: "Bind the block to a layout slot or give it an explicit frame", + automaticFixAvailable: true, }); } } - const score = calculateScore(issues); - const blockCoverage = calculateBlockCoverage(deck); - const estimates = calculateParityEstimates(deck); + // Check for errors + const hasErrors = issues.some((issue) => issue.severity === "error"); - const visible = deck.slides - .filter((slide) => !slide.hidden) - .flatMap((slide) => slide.blocks) - .filter((block) => !block.hidden); + // Parity estimates are 0..1 fractions, not percentages (exported contract). + const estimatedMissing = missingCount; + const estimatedFallbacks = fallbackCount; + const estimatedRecall = + visibleCount > 0 ? (visibleCount - estimatedMissing) / visibleCount : 1; + + // Coverage invariants: expected == native + fallback and missing == 0. + const coverage: ExportCoverage = { + expected: visibleCount, + native: nativeCount, + fallback: fallbackCount, + missing: estimatedMissing + geometryMissingCount, + satisfied: estimatedMissing === 0 && geometryMissingCount === 0, + }; + + // Group issues by category + const groups: PreflightGroupSummary[] = [ + { + group: "geometry", + label: "Geometry", + count: issues.filter((i) => i.code === "invalid-geometry").length, + issues: issues.filter((i) => i.code === "invalid-geometry"), + }, + { + group: "assets", + label: "Assets", + count: issues.filter((i) => i.code === "unresolved-image" || i.code === "image-load-failed").length, + issues: issues.filter((i) => i.code === "unresolved-image" || i.code === "image-load-failed"), + }, + { + group: "content", + label: "Content", + count: issues.filter((i) => i.code === "chart-no-data").length, + issues: issues.filter((i) => i.code === "chart-no-data"), + }, + { + group: "structural", + label: "Structural", + count: issues.filter((i) => + i.code === "block-hidden-skipped" || + i.code === "duplicate-element-id" || + i.code === "unsupported-block-type" + ).length, + issues: issues.filter((i) => + i.code === "block-hidden-skipped" || + i.code === "duplicate-element-id" || + i.code === "unsupported-block-type" + ), + }, + ]; return { issues, - score, - blockCoverage, - ...estimates, - missingBlockCount: estimates.estimatedMissing, - unsupportedBlockCount: estimates.estimatedMissing, - chartBlockCount: visible.filter((block) => block.type === "chart").length, + score: calculateScore(issues), + blockCoverage: visibleCount > 0 ? nativeCount / visibleCount : 1, + estimatedFallbacks, + estimatedRecall, + estimatedMissing, + missingBlockCount: estimatedMissing, + unsupportedBlockCount: estimatedMissing, + chartBlockCount, + ready: !hasErrors && estimatedMissing === 0 && geometryMissingCount === 0, + geometryMissingCount, + visibleBlockCount: visibleCount, + coverage, + groups, }; } + +/** + * Compare two snapshots for content parity. + * Used to validate that web and export have the same content. + */ +export function compareSnapshots( + webSnapshot: ImmutableSlideSnapshot, + exportSnapshot: ImmutableSlideSnapshot +): { + match: boolean; + differences: string[]; +} { + const differences: string[] = []; + + // Compare slide IDs + if (webSnapshot.slideId !== exportSnapshot.slideId) { + differences.push(`Slide ID mismatch: ${webSnapshot.slideId} vs ${exportSnapshot.slideId}`); + } + + // Compare block count + if (webSnapshot.blocks.length !== exportSnapshot.blocks.length) { + differences.push( + `Block count mismatch: ${webSnapshot.blocks.length} vs ${exportSnapshot.blocks.length}` + ); + } + + // Compare block IDs + const webBlockIds = webSnapshot.blocks.map((b) => b.id).sort(); + const exportBlockIds = exportSnapshot.blocks.map((b) => b.id).sort(); + if (JSON.stringify(webBlockIds) !== JSON.stringify(exportBlockIds)) { + differences.push(`Block IDs mismatch: ${webBlockIds.join(",")} vs ${exportBlockIds.join(",")}`); + } + + // Compare block types + for (const webBlock of webSnapshot.blocks) { + const exportBlock = exportSnapshot.blocks.find((b) => b.id === webBlock.id); + if (!exportBlock) { + differences.push(`Block ${webBlock.id} missing in export snapshot`); + continue; + } + + if (webBlock.type !== exportBlock.type) { + differences.push( + `Block ${webBlock.id} type mismatch: ${webBlock.type} vs ${exportBlock.type}` + ); + } + } + + // Compare semantic content + const webHash = hashSlideSemanticContent(webSnapshot); + const exportHash = hashSlideSemanticContent(exportSnapshot); + if (webHash !== exportHash) { + differences.push(`Semantic content mismatch`); + } + + return { + match: differences.length === 0, + differences, + }; +} \ No newline at end of file diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/export-scene.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/export-scene.ts new file mode 100644 index 0000000..6f712d8 --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/export-scene.ts @@ -0,0 +1,156 @@ +// export/export-scene.ts +// +// Structural validation of a fully-resolved export scene (Phase 16): +// catches the kinds of corruption that previously produced silent (0,0) +// geometry or leaked placeholder content into the deck file. Pure and +// framework-free so it can run in tests and CI. + +import type { PptxExportContext, PptxSlideElement, ExportIssueCode } from "./export-types"; +import { + aspectMatches, + validateRectWithinSlide, + validateFrame, +} from "./geometry"; + +export type SceneSeverity = "error" | "warning"; + +export interface ExportSceneDiagnostic { + code: ExportIssueCode; + severity: SceneSeverity; + slideId?: string; + elementId?: string; + message: string; +} + +export interface ExportScene { + slides: Array<{ slideId: string; elements: PptxSlideElement[] }>; +} + +/** + * The "New chart" template is not detected by its title (never special-case + * titles). Template charts are instead excluded at the source: chart blocks + * with `isTemplate: true` never reach the exported element list, and the + * exporter enforces "every exported chart maps to a visible source block". + */ + +/** + * Detect fallback elements that stand in for failed images. + */ +function detectUnresolvedImages(slide: ExportScene["slides"][number]): ExportSceneDiagnostic[] { + return slide.elements + .filter((element) => element.type === "fallback") + .map((element): ExportSceneDiagnostic[] => { + const text = (element.data as { text?: string }).text ?? ""; + if (/image unavailable/i.test(text)) { + return [ + { + code: "unresolved-image", + severity: "warning", + slideId: slide.slideId, + elementId: element.elementId, + message: `Slide "${slide.slideId}" contains an image that could not be resolved (element "${element.elementId}"); it was replaced with a placeholder`, + }, + ]; + } + return []; + }) + .flat(); +} + +/** Detect malformed or missing element geometry. */ +function detectGeometryErrors( + slide: ExportScene["slides"][number], + ctx: PptxExportContext, +): ExportSceneDiagnostic[] { + return slide.elements + .map((element): ExportSceneDiagnostic[] => { + const frame = { x: element.x, y: element.y, w: element.w, h: element.h }; + const errors = validateRectWithinSlide(frame, ctx.slideWidth, ctx.slideHeight); + if (errors.length) { + return [ + { + code: "invalid-geometry", + severity: "error", + slideId: slide.slideId, + elementId: element.elementId, + message: `Slide "${slide.slideId}" element "${element.elementId}" has invalid geometry: ${errors.join("; ")}`, + }, + ]; + } + return []; + }) + .flat(); +} + +/** Detect duplicate element ids across the whole deck (corrupt file risk). */ +function detectDuplicateElementIds( + slides: ExportScene["slides"], +): ExportSceneDiagnostic[] { + const seen = new Map<string, string>(); + const diagnostics: ExportSceneDiagnostic[] = []; + for (const slide of slides) { + for (const element of slide.elements) { + const id = element.elementId; + if (!id) continue; + const existingSlide = seen.get(id); + if (existingSlide !== undefined && existingSlide !== slide.slideId) { + diagnostics.push({ + code: "duplicate-element-id", + severity: "warning", + slideId: slide.slideId, + elementId: id, + message: `Element id "${id}" appears on both slide "${existingSlide}" and "${slide.slideId}"; duplicate ids can break edit targeting`, + }); + } else { + seen.set(id, slide.slideId); + } + } + } + return diagnostics; +} + +/** Detect the PPTX/web aspect mismatch that hard-coded 13.333"x7.5" caused. */ +function detectAspectMismatch(ctx: PptxExportContext): ExportSceneDiagnostic[] { + const matches = aspectMatches( + ctx.slideWidth, + ctx.slideHeight, + ctx.pptxWidth, + ctx.pptxHeight, + ); + if (!matches) { + return [ + { + code: "aspect-mismatch", + severity: "error", + message: `PPTX slide size (${ctx.pptxWidth}"x${ctx.pptxHeight}") does not match the document canvas aspect ratio (${ctx.slideWidth}x${ctx.slideHeight}px); exports will be distorted`, + }, + ]; + } + return []; +} + +/** + * Validate a fully-resolved export scene. Returns a list of diagnostics + * grouped by severity; the export pipeline surfaces errors as failed status. + */ +export function validateExportScene( + scene: ExportScene, + ctx: PptxExportContext, +): ExportSceneDiagnostic[] { + const diagnostics: ExportSceneDiagnostic[] = [ + ...detectAspectMismatch(ctx), + ...detectDuplicateElementIds(scene.slides), + ]; + for (const slide of scene.slides) { + diagnostics.push(...detectGeometryErrors(slide, ctx)); + diagnostics.push(...detectUnresolvedImages(slide)); + } + return diagnostics; +} + +/** True when a scene has at least one error-severity diagnostic. */ +export function sceneHasErrors(diagnostics: ExportSceneDiagnostic[]): boolean { + return diagnostics.some((diagnostic) => diagnostic.severity === "error"); +} + +export { validateFrame }; \ No newline at end of file diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/export-types.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/export-types.ts index 96f1f89..8c1ff09 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/export-types.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/export-types.ts @@ -1,5 +1,6 @@ -import type { Block, DeckProject } from "../deck-types"; -import type { AssetEmbedResult } from "./pptx/pptx-assets"; +import type { Block, DeckProject, SaveState } from "../deck/types"; +import type { PreparedAsset } from "./prepare-export"; +import type { Command, DispatchResult } from "../deck/commands"; export type ExportIssueSeverity = "info" | "warning" | "error"; @@ -18,7 +19,17 @@ export type ExportIssueCode = | "hidden-slide-skipped" | "missing-speaker-notes" | "external-asset" - | "no-fallback-produced"; + | "no-fallback-produced" + | "empty-table" + | "template-chart-skipped" + | "chart-no-data" + | "invalid-geometry" + | "aspect-mismatch" + | "duplicate-element-id" + | "unresolved-image" + | "template-chart-leak" + | "chart-data-mismatch" + | "chart-count-mismatch"; export interface ExportIssue { code: ExportIssueCode; @@ -97,6 +108,24 @@ export type PptxExportability = | "poster-with-link" | "unsupported"; +export type PreflightIssueGroup = "geometry" | "assets" | "content" | "structural"; + +export interface PreflightGroupSummary { + group: PreflightIssueGroup; + label: string; + count: number; + issues: ExportIssue[]; +} + +/** Coverage invariants: expected == native + fallback and missing == 0. */ +export interface ExportCoverage { + expected: number; + native: number; + fallback: number; + missing: number; + satisfied: boolean; +} + export interface ExportPreflightResult { issues: ExportIssue[]; score: number; @@ -107,6 +136,16 @@ export interface ExportPreflightResult { missingBlockCount: number; unsupportedBlockCount: number; chartBlockCount: number; + /** True when export may proceed cleanly (no errors, zero missing geometry). */ + ready: boolean; + /** Visible blocks with no resolvable canonical frame (fail-close gate). */ + geometryMissingCount: number; + /** Number of visible blocks (the "expected" denominator). */ + visibleBlockCount: number; + /** Coverage invariants over the resolved scene. */ + coverage: ExportCoverage; + /** Diagnostics grouped by pipeline stage for the UI. */ + groups: PreflightGroupSummary[]; } export interface PptxExportConfig { @@ -125,13 +164,23 @@ export interface FontWarning { substituteFont?: string; } +/** A single styled run inside a native text element (pptxgenjs TextProps). */ +export interface PptxTextRun { + text: string; + options?: Record<string, unknown>; +} + interface PptxTextElement { type: "text"; x: number; y: number; w: number; h: number; - data: { text: string; options?: Record<string, unknown> }; + /** Stable identity of the source block (SlideDocument) for validation/diagnostics. */ + elementId?: string; + /** Stable identity of the owning slide. */ + slideId?: string; + data: { text: string | PptxTextRun[]; options?: Record<string, unknown> }; } interface PptxImageElement { @@ -140,7 +189,19 @@ interface PptxImageElement { y: number; w: number; h: number; - data: { dataUri: string; alt?: string; options?: Record<string, unknown> }; + /** Stable identity of the source block (SlideDocument) for validation/diagnostics. */ + elementId?: string; + /** Stable identity of the owning slide. */ + slideId?: string; + data: { + dataUri: string; + alt?: string; + /** Intrinsic pixel dimensions of the source image, when known. Used to + * crop cover/contain from the real aspect ratio instead of stretching. */ + naturalWidth?: number; + naturalHeight?: number; + options?: Record<string, unknown>; + }; } interface PptxShapeElement { @@ -149,6 +210,10 @@ interface PptxShapeElement { y: number; w: number; h: number; + /** Stable identity of the source block (SlideDocument) for validation/diagnostics. */ + elementId?: string; + /** Stable identity of the owning slide. */ + slideId?: string; data: { shape: string; options?: Record<string, unknown> }; } @@ -158,6 +223,10 @@ interface PptxTableElement { y: number; w: number; h: number; + /** Stable identity of the source block (SlideDocument) for validation/diagnostics. */ + elementId?: string; + /** Stable identity of the owning slide. */ + slideId?: string; data: { rows: unknown[][]; options?: Record<string, unknown> }; } @@ -167,6 +236,10 @@ interface PptxChartElement { y: number; w: number; h: number; + /** Stable identity of the source block (SlideDocument) for validation/diagnostics. */ + elementId?: string; + /** Stable identity of the owning slide. */ + slideId?: string; data: { chartType: string; data: unknown[]; options?: Record<string, unknown> }; } @@ -176,6 +249,10 @@ interface PptxFallbackElement { y: number; w: number; h: number; + /** Stable identity of the source block (SlideDocument) for validation/diagnostics. */ + elementId?: string; + /** Stable identity of the owning slide. */ + slideId?: string; data: { text: string; options?: Record<string, unknown> }; } @@ -185,6 +262,10 @@ interface PptxSvgElement { y: number; w: number; h: number; + /** Stable identity of the source block (SlideDocument) for validation/diagnostics. */ + elementId?: string; + /** Stable identity of the owning slide. */ + slideId?: string; data: { svg: string; alt?: string; options?: Record<string, unknown> }; } @@ -210,13 +291,34 @@ export interface PptxExportContext { deck: DeckProject; config: PptxExportConfig; fontWarnings: FontWarning[]; - assetCache: Map<string, AssetEmbedResult>; + /** + * The canonical, pre-resolved asset registry produced by the single + * `prepareExport` phase. Exporters MUST consume resolved bytes from here and + * must never fetch or re-resolve an asset on their own. Keyed by canonical + * asset id (manifest id or `inline:<blockId>`). + */ + assetRegistry: ReadonlyMap<string, PreparedAsset>; + /** Document pixel width of the slide (from SlideDocument.canvas). */ slideWidth: number; + /** Document pixel height of the slide (from SlideDocument.canvas). */ slideHeight: number; + /** + * PowerPoint slide size in inches, DERIVED from the document aspect ratio + * (Phase 4). webAspect === pptxAspect always. Never a hard-coded 13.333x7.5. + */ + pptxWidth: number; + pptxHeight: number; } export interface PptxBlockExport { element?: PptxSlideElement; + /** + * Additional elements produced by one source block (e.g. a process diagram + * rendered as several editable shapes + connectors). When present, all of + * them are written to the slide; `element` remains the primary representative + * used for representation planning. + */ + elements?: PptxSlideElement[]; status: BlockExportStatus; issues: ExportIssue[]; } @@ -233,6 +335,8 @@ export interface ExportDialogProps { onClose: () => void; onExport?: (result: Blob) => void; onError?: (error: Error) => void; + commit?: (command: Command) => DispatchResult | undefined; + saveNow?: (deck: DeckProject) => SaveState; } export const DEFAULT_PPTX_CONFIG: PptxExportConfig = { diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/content-parity.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/content-parity.ts index 2d9e4f6..4ee86ac 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/content-parity.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/content-parity.ts @@ -1,4 +1,4 @@ -import type { Block, DeckProject } from "../../deck-types"; +import type { Block, DeckProject } from "../../deck/types"; import type { FidelityBlockReport, PptxFidelityPolicy } from "./fidelity-types"; import { FIDELITY_POLICY } from "./fidelity-policy"; @@ -11,28 +11,41 @@ function asRecord(value: unknown): ContentRecord { } export function rawText(block: Block): string { - if (typeof block.content === "string") return block.content; - return String(asRecord(block.content).text ?? ""); + const content = block.content; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + // bullets: array of lines / {text} + return content + .map((item) => + typeof item === "string" ? item : typeof (item as { text?: unknown })?.text === "string" + ? ((item as { text: string }).text) + : "", + ) + .join(" "); + } + if (!content || typeof content !== "object") return ""; + const record = asRecord(content); + if (typeof record.text === "string") return record.text; + // metric: { value, label, delta } + if (record.value != null || record.label != null || record.delta != null) { + return [record.value, record.label, record.delta].filter((v) => typeof v === "string").join(" "); + } + // process: { steps: [{ title, detail }] } + if (Array.isArray(record.steps)) { + return record.steps + .map((step) => { + const s = asRecord(step); + return [s.title, s.detail].filter((v) => typeof v === "string").join(" "); + }) + .join(" "); + } + return ""; } function meaningfulText(block: Block): number { return (rawText(block).match(VISIBLE_TEXT) ?? []).length; } -/** - * Compute text-recall content parity: the ratio of meaningful text tokens - * present in the export to the total expected across all visible blocks. - * - * This is a TEXT-based metric — it measures how much human-readable text - * survives into the PPTX output. For visual blocks (charts, diagrams, - * images) where the exported representation is SVG or raster, the metric - * falls back to the block's alt text or title. A score of 1.0 means all - * expected text is present; 0.0 means no text was exported. - * - * The metric intentionally does NOT measure visual fidelity (pixel-level - * accuracy) or structural fidelity (layout positions). Those are assessed - * separately by the OOXML structural verifier. - */ export function calculateContentParity( deck: DeckProject, blocks: FidelityBlockReport[], diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/fidelity-report.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/fidelity-report.ts index 79096d7..24c012b 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/fidelity-report.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/fidelity-report.ts @@ -1,4 +1,4 @@ -import type { DeckProject } from "../../deck-types"; +import type { DeckProject } from "../../deck/types"; import type { FidelityReport } from "../export-types"; import { calculateContentParity } from "./content-parity"; import { FIDELITY_POLICY } from "./fidelity-policy"; diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/svg/svg-chart.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/svg/svg-chart.ts new file mode 100644 index 0000000..80ea541 --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/svg/svg-chart.ts @@ -0,0 +1,233 @@ +import type { ResolvedChartSpec } from "../../snapshot"; + +function escapeXml(text: string): string { + return text + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function formatValue(value: number, unit: string): string { + return `${value}${unit}`; +} + +// ─── Vertical Bar Chart ────────────────────────────────────────────────────── + +interface ChartPlotRect { + x: number; + y: number; + w: number; + h: number; +} + +interface ChartGridline { + fraction: number; + y: number; + label: string; + labelX: number; + labelY: number; +} + +interface BarPlacement { + barX: number; + barY: number; + barW: number; + barH: number; + dataLabelX: number; + dataLabelY: number; + categoryLabelX: number; + categoryLabelY: number; + dataLabel: string; +} + +interface BarChartLayout { + plot: ChartPlotRect; + gridlines: ChartGridline[]; + bars: BarPlacement[]; + axisLabelAnchor: "end" | "start"; +} + +function describeBarChartLayout(spec: ResolvedChartSpec): BarChartLayout { + const width = 560; + const height = 300; + const values = spec.series[0]?.values ?? []; + const categories = spec.categories; + const titleH = spec.title ? 20 : 0; + const padX = 8; + const categoryH = 18; + const baselineH = 8; + const axisW = 46; + const dataLabelPadding = 18; + const showDataLabels = true; + const dataPosition = "out-end"; + const dataPad = showDataLabels && dataPosition === "out-end" ? dataLabelPadding : 0; + + const axisOnLeft = true; + const plot: ChartPlotRect = { + x: padX + (axisOnLeft ? axisW : 0), + y: titleH + dataPad, + w: width - padX * 2 - axisW, + h: height - titleH - dataPad - baselineH - categoryH, + }; + + const maxValue = values.length ? Math.max(...values, 1) : 1; + const slotW = values.length ? plot.w / values.length : plot.w; + const barW = Math.min(slotW * 0.55, 42); + + const fractions = [0, 0.25, 0.5, 0.75, 1]; + const gridlines: ChartGridline[] = fractions.map((fraction) => { + const y = plot.y + plot.h - fraction * plot.h; + const raw = Math.round(fraction * maxValue * 10) / 10; + const label = String(raw); + return { + fraction, + y, + label, + labelX: axisOnLeft ? plot.x - 4 : plot.x + plot.w + 4, + labelY: y - 4, + }; + }); + + const bars: BarPlacement[] = values.map((value, index) => { + const h = (value / maxValue) * plot.h; + const x = plot.x + index * slotW + (slotW - barW) / 2; + const y = plot.y + plot.h - h; + return { + barX: x, + barY: y, + barW, + barH: Math.max(h, 2), + dataLabelX: x + barW / 2, + dataLabelY: y - 4, + categoryLabelX: x + barW / 2, + categoryLabelY: plot.y + plot.h + baselineH + categoryH - 5, + dataLabel: formatValue(value, spec.unit), + }; + }); + + return { + plot, + gridlines, + bars, + axisLabelAnchor: axisOnLeft ? "end" : "start", + }; +} + +function renderVerticalBar(spec: ResolvedChartSpec): string { + const layout = describeBarChartLayout(spec); + const { plot, gridlines, bars } = layout; + const values = spec.series[0]?.values ?? []; + const style = spec.style; + const fontFamily = escapeXml(style.fontFamily); + + const parts: string[] = []; + + // Title + if (spec.title) { + parts.push( + `<text x="8" y="14" font-size="13" font-weight="600" fill="${style.labelColor}" font-family="${fontFamily}">${escapeXml(spec.title)}</text>` + ); + } + + // Baseline + parts.push( + `<line x1="${plot.x}" x2="${plot.x + plot.w}" y1="${plot.y + plot.h}" y2="${plot.y + plot.h}" stroke="${style.axisColor}" stroke-width="1"/>` + ); + + // Gridlines + axis labels + for (const gridline of gridlines) { + const dashArray = gridline.fraction === 0 ? "none" : "3 4"; + parts.push( + `<line x1="${plot.x}" x2="${plot.x + plot.w}" y1="${gridline.y}" y2="${gridline.y}" stroke="${style.axisColor}" stroke-width="1" stroke-dasharray="${dashArray}"/>` + ); + parts.push( + `<text x="${gridline.labelX}" y="${gridline.labelY}" font-size="9" fill="${style.labelColor}" text-anchor="${layout.axisLabelAnchor}" font-family="${fontFamily}">${escapeXml(gridline.label)}</text>` + ); + } + + // Bars + labels + for (let index = 0; index < values.length; index++) { + const placement = bars[index]; + const isHighlight = spec.highlightIndex === index; + const fill = style.seriesColors[index] ?? style.accentColor; + const label = spec.categories[index] ?? ""; + + parts.push( + `<rect x="${placement.barX}" y="${placement.barY}" width="${placement.barW}" height="${placement.barH}" rx="3" fill="${fill}"/>` + ); + parts.push( + `<text x="${placement.categoryLabelX}" y="${placement.categoryLabelY}" font-size="10" fill="${style.labelColor}" text-anchor="middle" font-family="${fontFamily}">${escapeXml(label)}</text>` + ); + parts.push( + `<text x="${placement.dataLabelX}" y="${placement.dataLabelY}" font-size="10" font-weight="600" fill="${isHighlight ? style.highlightColor : style.foreground}" text-anchor="middle" font-family="${fontFamily}">${escapeXml(placement.dataLabel)}</text>` + ); + } + + return parts.join("\n"); +} + +// ─── Horizontal Bar Chart ──────────────────────────────────────────────────── + +function renderHorizontalBar(spec: ResolvedChartSpec): string { + const values = spec.series[0]?.values ?? []; + const categories = spec.categories; + const style = spec.style; + const fontFamily = escapeXml(style.fontFamily); + const max = Math.max(...values, 1); + const titleH = spec.title ? 20 : 0; + const rowH = 40; + const labelW = 96; + const barMaxW = 560 - labelW - 56; + const top = titleH + 8; + + const parts: string[] = []; + + // Title + if (spec.title) { + parts.push( + `<text x="0" y="14" font-size="13" font-weight="600" fill="${style.labelColor}" font-family="${fontFamily}">${escapeXml(spec.title)}</text>` + ); + } + + // Rows + for (let index = 0; index < values.length; index++) { + const value = values[index]; + const y = top + index * rowH; + const w = (value / max) * barMaxW; + const isHighlight = spec.highlightIndex === index; + const fill = style.seriesColors[index] ?? style.accentColor; + const label = categories[index] ?? ""; + + parts.push( + `<text x="0" y="${y + 16}" font-size="12" fill="${style.labelColor}" text-anchor="start" font-family="${fontFamily}">${escapeXml(label)}</text>` + ); + parts.push( + `<rect x="${labelW}" y="${y + 2}" width="${Math.max(w, 2)}" height="20" rx="3" fill="${fill}"/>` + ); + parts.push( + `<text x="${labelW + w + 6}" y="${y + 17}" font-size="11" font-weight="600" fill="${isHighlight ? style.highlightColor : style.foreground}" font-family="${fontFamily}">${escapeXml(formatValue(value, spec.unit))}</text>` + ); + } + + return parts.join("\n"); +} + +// ─── Public API ────────────────────────────────────────────────────────────── + +export function renderChartToSvg(spec: ResolvedChartSpec): string { + const parts: string[] = []; + parts.push( + `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 560 300" width="100%" height="100%" role="img" aria-label="${escapeXml(spec.summary || "Chart")}">` + ); + + if (spec.orientation === "horizontal") { + parts.push(renderHorizontalBar(spec)); + } else { + parts.push(renderVerticalBar(spec)); + } + + parts.push("</svg>"); + return parts.join("\n"); +} diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/svg/svg-raster.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/svg/svg-raster.ts new file mode 100644 index 0000000..1627e16 --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/fidelity/svg/svg-raster.ts @@ -0,0 +1,45 @@ +// export/fidelity/svg/svg-raster.ts +// +// Rasterize an SVG string to a PNG buffer in pure Node. PptxGenJS embeds SVG +// images by generating a PNG *preview* in a browser `Image`/`canvas`, which is +// not available in Node, so any SVG fallback (charts, diagrams, video posters) +// is rasterized here instead and embedded as a crisp 2x PNG. +// +// Web-only theme fonts that are not installed on office machines are mapped to +// their export substitution before rendering so chart text is deterministic +// (the same mapping the PPTX text exporter uses). + +import { Resvg } from "@resvg/resvg-js"; + +/** Web font -> commonly installed office font, matching pptFontFor(). */ +const FONT_FALLBACK: Record<string, string> = { + Inter: "Arial", + "Libre Baskerville": "Georgia", + "JetBrains Mono": "Consolas", +}; + +function mapFontFamily(svg: string): string { + let out = svg; + for (const [from, to] of Object.entries(FONT_FALLBACK)) { + out = out.split(`font-family="${from}"`).join(`font-family="${to}"`); + } + return out; +} + +/** + * Render an SVG string to a PNG buffer at the given target width (px). Height + * follows the SVG's intrinsic aspect ratio (viewBox), so callers should only + * rasterize SVGs whose element keeps that aspect (charts: contained 560:300 + * box; diagrams: SVG already sized to the frame). + */ +export function renderSvgToPng(svg: string, pixelWidth: number): Buffer { + const resvg = new Resvg(mapFontFamily(svg), { + fitTo: { mode: "width", value: Math.max(1, Math.round(pixelWidth)) }, + background: "transparent", + }); + const rendered = resvg.render(); + if (!rendered || rendered.width === 0 || rendered.height === 0) { + throw new Error("SVG rasterization produced an empty image"); + } + return rendered.asPng(); +} diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/geometry.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/geometry.ts new file mode 100644 index 0000000..c83f279 --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/geometry.ts @@ -0,0 +1,194 @@ +/** + * export/geometry.ts + * + * THE canonical slide-coordinate geometry layer (DeckForge architecture rule). + * + * SlideDocument owns logical width/height + element geometry in DOCUMENT + * pixels. Everything else — editor zoom, pan, Fit, fullscreen, presenter + * letterboxing, and the PPTX surface — is a VIEW or SERIALIZATION transform + * and MUST NOT mutate document geometry. + * + * All pixel space exists in document coordinates. Conversions to PowerPoint + * units are pure, ratio-based, aspect-preserving functions centralised here so + * no individual exporter can invent its own coordinate mapping. + * + * Invariant: docW / docH === pptxW / pptxH + */ + +export interface Size { + width: number; + height: number; +} + +export interface Rect { + x: number; + y: number; + w: number; + h: number; +} + +/** Aspect ratio width/height. */ +export function aspectOf(width: number, height: number): number { + if (!isFinite(width) || !isFinite(height) || height <= 0) return NaN; + return width / height; +} + +/** True when two rectangles have the same aspect ratio within tolerance. */ +export function aspectMatches( + w1: number, + h1: number, + w2: number, + h2: number, + tolerance = 1e-6, +): boolean { + const a = aspectOf(w1, h1); + const b = aspectOf(w2, h2); + if (!isFinite(a) || !isFinite(b)) return false; + return Math.abs(a - b) <= tolerance; +} + +/** + * Derive a PowerPoint slide size (inches) that PRESERVES the document aspect + * ratio for any canvas resolution. `pxPerInch` fixes the physical density but + * never the aspect ratio: PPTX width is proportional to document pixel width + * and height follows from the aspect, so element geometry stays visually + * equivalent whether the canvas is 1600x900, 1920x1080, or 1920x800. + * + * Previously the exporter forced 13.333"x7.5" whenever the canvas was labelled + * "16:9", which silently distorted any canvas whose real pixels were not + * exactly 16:9. This function is the single source of truth instead. + */ +export function derivePptxSlideSize( + documentWidthPx: number, + documentHeightPx: number, + pxPerInch = 120, +): Size { + const safeW = sanitizeDimension(documentWidthPx, 1600); + const safeH = sanitizeDimension(documentHeightPx, 900); + const width = safeW / pxPerInch; + const height = safeH / pxPerInch; + return { width, height }; +} + +/** + * Map a document-coordinate rect into a PPTX rect (inches) by pure ratios + * (Phase 5). When aspect ratios match this is visually equivalent to the + * browser layout. Never falls back to (0,0): use validateFrame first. + */ +export function documentRectToPptxRect( + source: Pick<Rect, 'x' | 'y' | 'w' | 'h'>, + documentWidthPx: number, + documentHeightPx: number, + pptxWidthInches: number, + pptxHeightInches: number, +): Rect { + const xRatio = source.x / documentWidthPx; + const yRatio = source.y / documentHeightPx; + const wRatio = source.w / documentWidthPx; + const hRatio = source.h / documentHeightPx; + return { + x: xRatio * pptxWidthInches, + y: yRatio * pptxHeightInches, + w: wRatio * pptxWidthInches, + h: hRatio * pptxHeightInches, + }; +} + +/** + * Convert one document-pixel dimension to PPTX inches, proportional to the + * owning axis of the slide (deterministic; Phase 5/7). + */ +export function documentUnitToPptxInches( + px: number, + documentDimensionPx: number, + pptxDimensionInches: number, +): number { + if (!isFinite(documentDimensionPx) || documentDimensionPx <= 0) return 0; + return (px / documentDimensionPx) * pptxDimensionInches; +} + +/** + * Convert a browser (document-pixel) font size to PowerPoint points so that + * text scales with the slide (Phase 7). At matching aspect ratios this keeps + * the type exactly proportional to the browser layout instead of using + * unrelated hard-coded PPT sizes. + */ +export function browserFontSizeToPptPt( + fontSizePx: number, + documentHeightPx: number, + pptxHeightInches: number, +): number { + if (!isFinite(fontSizePx) || fontSizePx <= 0) return 11; + const ptPerPx = (pptxHeightInches * 72) / documentHeightPx; + return Math.round(fontSizePx * ptPerPx * 100) / 100; +} + +/** Clamp a container-query based font size (mirrors browser clamp() rules). */ +export function fontSizeFromCqw( + factor: number, + minPx: number, + maxPx: number, + containerWidthPx: number, +): number { + const raw = factor * (containerWidthPx / 100); + return Math.min(maxPx, Math.max(minPx, raw)); +} + +function sanitizeDimension(value: number, fallback: number): number { + return isFinite(value) && value > 0 ? value : fallback; +} + +/** + * Validate a frame before it is exported. Returns a list of human-readable + * errors. MISSING geometry is distinguished from a legitimate 0 coordinate: + * an explicit x=0 is valid; an undefined/NaN/negative/zero-size frame is not + * and must never be silently placed at the top-left corner. + */ +export function validateFrame( + source: Partial<Pick<Rect, 'x' | 'y' | 'w' | 'h'>>, + options?: { allowOutside?: boolean }, +): string[] { + const errors: string[] = []; + for (const key of ['x', 'y', 'w', 'h'] as const) { + const value = source[key]; + if (value === undefined) { + errors.push(`missing "${key}"`); + continue; + } + if (typeof value !== 'number' || !isFinite(value)) { + errors.push(`"${key}" is not a finite number`); + continue; + } + if ((key === 'w' || key === 'h') && value <= 0) { + errors.push(`"${key}" must be > 0 (got ${value})`); + } + if ((key === 'x' || key === 'y') && value < 0) { + errors.push(`"${key}" must be >= 0 (got ${value})`); + } + } + return errors; +} + +/** Full validation of a rect against the document bounds (Phase 16 diagnostics). */ +export function validateRectWithinSlide( + rect: Partial<Pick<Rect, 'x' | 'y' | 'w' | 'h'>>, + documentWidthPx: number, + documentHeightPx: number, + tolerance = 1, +): string[] { + const errors = validateFrame(rect); + if (errors.length) return errors; + const { x = 0, y = 0, w = 0, h = 0 } = rect as Rect; + if (x + w > documentWidthPx + tolerance) { + errors.push(`rect exceeds slide width (x+w=${x + w} > ${documentWidthPx})`); + } + if (y + h > documentHeightPx + tolerance) { + errors.push(`rect exceeds slide height (y+h=${y + h} > ${documentHeightPx})`); + } + return errors; +} + +/** True when a rect is valid enough to be placed (never false-negatives). */ +export function isUsableFrame(source: Partial<Pick<Rect, 'x' | 'y' | 'w' | 'h'>>): boolean { + return validateFrame(source).length === 0; +} \ No newline at end of file diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/image-dimensions.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/image-dimensions.ts new file mode 100644 index 0000000..c5c18c2 --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/image-dimensions.ts @@ -0,0 +1,167 @@ +export interface IntrinsicImageSize { + width: number; + height: number; +} + +function toBytes(dataUri: string): Uint8Array { + const comma = dataUri.indexOf(","); + if (comma < 0) { + return new Uint8Array(0); + } + const header = dataUri.slice(0, comma); + const payload = dataUri.slice(comma + 1); + if (/;base64$/i.test(header)) { + const binary = atob(payload); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; + } + const decoded = decodeURIComponent(payload); + const bytes = new Uint8Array(decoded.length); + for (let i = 0; i < decoded.length; i += 1) { + bytes[i] = decoded.charCodeAt(i); + } + return bytes; +} + +function readPng(bytes: Uint8Array): IntrinsicImageSize | null { + if (bytes.length < 24) { + return null; + } + const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + for (let i = 0; i < signature.length; i += 1) { + if (bytes[i] !== signature[i]) { + return null; + } + } + if (bytes[12] !== 0x49 || bytes[13] !== 0x48 || bytes[14] !== 0x44 || bytes[15] !== 0x52) { + return null; + } + const width = (bytes[16] << 24) | (bytes[17] << 16) | (bytes[18] << 8) | bytes[19]; + const height = (bytes[20] << 24) | (bytes[21] << 16) | (bytes[22] << 8) | bytes[23]; + if (width <= 0 || height <= 0) { + return null; + } + return { width, height }; +} + +function readGif(bytes: Uint8Array): IntrinsicImageSize | null { + if (bytes.length < 10) { + return null; + } + if (bytes[0] !== 0x47 || bytes[1] !== 0x49 || bytes[2] !== 0x46) { + return null; + } + const width = bytes[6] | (bytes[7] << 8); + const height = bytes[8] | (bytes[9] << 8); + if (width <= 0 || height <= 0) { + return null; + } + return { width, height }; +} + +function readJpeg(bytes: Uint8Array): IntrinsicImageSize | null { + if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) { + return null; + } + let offset = 2; + while (offset + 4 <= bytes.length) { + if (bytes[offset] !== 0xff) { + offset += 1; + continue; + } + const marker = bytes[offset + 1]; + if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) { + offset += 2; + continue; + } + if (marker === 0xff || marker === 0x01) { + offset += 2; + continue; + } + const length = (bytes[offset + 2] << 8) | bytes[offset + 3]; + if (length < 2 || offset + 2 + length > bytes.length) { + return null; + } + const isSof = + marker === 0xc0 || + marker === 0xc1 || + marker === 0xc2 || + marker === 0xc3 || + marker === 0xc5 || + marker === 0xc6 || + marker === 0xc7 || + marker === 0xc9 || + marker === 0xca || + marker === 0xcb || + marker === 0xcd || + marker === 0xce || + marker === 0xcf; + if (isSof) { + if (offset + 9 > bytes.length) { + return null; + } + const height = (bytes[offset + 5] << 8) | bytes[offset + 6]; + const width = (bytes[offset + 7] << 8) | bytes[offset + 8]; + if (width <= 0 || height <= 0) { + return null; + } + return { width, height }; + } + offset += 2 + length; + } + return null; +} + +function readWebP(bytes: Uint8Array): IntrinsicImageSize | null { + if (bytes.length < 30) { + return null; + } + if (bytes[0] !== 0x52 || bytes[1] !== 0x49 || bytes[2] !== 0x46 || bytes[3] !== 0x46) { + return null; + } + if (bytes[8] !== 0x57 || bytes[9] !== 0x45 || bytes[10] !== 0x42 || bytes[11] !== 0x50) { + return null; + } + const chunkHeader = String.fromCharCode(bytes[12], bytes[13], bytes[14], bytes[15]); + if (chunkHeader === "VP8X") { + const width = 1 + (bytes[24] | (bytes[25] << 8) | (bytes[26] << 16)); + const height = 1 + (bytes[27] | (bytes[28] << 8) | (bytes[29] << 16)); + if (width <= 0 || height <= 0) { + return null; + } + return { width, height }; + } + if (chunkHeader === "VP8 " && bytes[20] !== 0x2f) { + const width = (bytes[26] | (bytes[27] << 8)) & 0x3fff; + const height = (bytes[28] | (bytes[29] << 8)) & 0x3fff; + if (width <= 0 || height <= 0) { + return null; + } + return { width, height }; + } + if (chunkHeader === "VP8L") { + const width = 1 + (((bytes[22] & 0x3f) << 8) | bytes[21]); + const height = 1 + (((bytes[24] & 0x0f) << 10) | (bytes[23] << 2) | ((bytes[22] & 0xc0) >> 6)); + if (width <= 0 || height <= 0) { + return null; + } + return { width, height }; + } + return null; +} + +export function readImageSizeFromDataUri(dataUri: string): IntrinsicImageSize | null { + if (typeof dataUri !== "string" || !dataUri.startsWith("data:")) { + return null; + } + const bytes = toBytes(dataUri); + if (bytes.length === 0) { + return null; + } + return ( + readPng(bytes) ?? readJpeg(bytes) ?? readGif(bytes) ?? readWebP(bytes) + ); +} diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/index.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/index.ts index d4d4082..eb5fd2d 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/index.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/index.ts @@ -13,34 +13,123 @@ export type { PptxExportContext, PptxExportability, PptxSlideElement, + PptxSlideElementType, PptxBlockExport, PptxBlockExporter, PptxExportResult, + PptxTextRun, FontWarning, ExportDialogProps, BlockRepresentation, PptxVerificationCheck, PptxVerificationReport, FidelityReport, + ExportCoverage, + PreflightGroupSummary, + PreflightIssueGroup, } from "./export-types"; export { DEFAULT_PPTX_CONFIG } from "./export-types"; +export { + isUsableFrame, + aspectOf, + aspectMatches, + derivePptxSlideSize, + documentRectToPptxRect, + documentUnitToPptxInches, + browserFontSizeToPptPt, + fontSizeFromCqw, + validateFrame, + validateRectWithinSlide, +} from "./geometry"; +export type { Rect, Size } from "./geometry"; + +export { + resolveSlideSnapshot, + resolveChartSpecForBlock, + createDeckSnapshot, + validateSnapshot, + hashSlideSemanticContent, +} from "./snapshot"; +export type { + ImmutableSlideSnapshot, + ResolvedAssetSnapshot, + ResolvedBlockSnapshot, + ResolvedChartSpec, + ResolvedChartStyle, + ResolvedPaint, + ResolvedTextStyle, + ResolvedThemeSnapshot, +} from "./snapshot"; + +export { prepareExport, isPreparedExport } from "./prepare-export"; +export type { PreparedExport, PreparedAsset, PreparedAssetStatus } from "./prepare-export"; + +export { makeDeckSelfContained } from "./self-contained"; +export type { EmbedFn, SelfContainedFailure, SelfContainedResult } from "./self-contained"; + +export { + resolveTheme, + normalizeColor, + hexToRgb, + hexToPptx, + resolvePptxFont, + isPptxSafeFont, + resolveChartColors, + resolveTextColor, +} from "./resolved-theme"; +export type { ResolvedTheme } from "./resolved-theme"; + +export { readImageSizeFromDataUri } from "./image-dimensions"; +export type { IntrinsicImageSize } from "./image-dimensions"; + +export { validateExportScene, sceneHasErrors } from "./export-scene"; +export type { ExportScene, ExportSceneDiagnostic, SceneSeverity } from "./export-scene"; + +export { renderChartToSvg } from "./fidelity/svg/svg-chart"; +export { renderSvgToPng } from "./fidelity/svg/svg-raster"; +export { renderDiagramSvg, normalizeDiagram } from "./fidelity/svg/svg-diagram"; +export type { DiagramInput, DiagramNodeInput, DiagramEdgeInput, DiagramSvgOptions } from "./fidelity/svg/svg-diagram"; +export { renderSnapshotSvg } from "./fidelity/svg/svg-snapshot"; +export type { SnapshotSvgOptions } from "./fidelity/svg/svg-snapshot"; + +export { PLACEHOLDER_IMAGE_DATA_URI } from "./pptx/pptx-placeholder"; + +export { + exportFrameOf, + frameErrorIssue, + frameValidation, + browserTypographyFor, + fontSizeToPpt, + pptFontFor, + textFrameOptions, + estimateTextHeightPx, +} from "./pptx/export-utils"; +export type { BrowserTypography } from "./pptx/export-utils"; + export { PptxExporter, buildExportReport, deriveExportStatus } from "./pptx/pptx-exporter"; +export type { ExportBuildResult } from "./pptx/pptx-exporter"; export { verifyPptxArchive } from "./pptx/pptx-verifier"; +export type { VerificationInput } from "./pptx/pptx-verifier"; export { createExportContext } from "./pptx/pptx-context"; -export { mapThemeColors, mapThemeFonts, applyThemeToPptx } from "./pptx/pptx-theme"; +export { mapThemeColors, mapThemeFonts } from "./pptx/pptx-theme"; export { checkFontCompatibility, collectFontWarnings } from "./pptx/pptx-fonts"; -export { embedAsset, embedAssetSync } from "./pptx/pptx-assets"; +export { embedAsset, embedAssetDetailed, embedAssetSync } from "./pptx/pptx-assets"; +export type { AssetEmbedResult, EmbedOutcome } from "./pptx/pptx-assets"; export { renderFallback } from "./pptx/pptx-fallback-renderer"; -export { - blockExporters, - getBlockExporter, - getExportability, -} from "./pptx/block-exporters/index"; +export { blockExporters, getBlockExporter, getExportability } from "./pptx/block-exporters/index"; -export { textBlockExporter, headingBlockExporter, bulletsBlockExporter, calloutBlockExporter, citationBlockExporter, metricBlockExporter, processBlockExporter } from "./pptx/block-exporters/text"; +export { + textBlockExporter, + headingBlockExporter, + bulletsBlockExporter, + calloutBlockExporter, + citationBlockExporter, + metricBlockExporter, +} from "./pptx/block-exporters/text"; +export { processBlockExporter } from "./pptx/block-exporters/process"; export { imageBlockExporter } from "./pptx/block-exporters/image"; export { shapeBlockExporter } from "./pptx/block-exporters/shape"; export { tableBlockExporter } from "./pptx/block-exporters/table"; @@ -52,18 +141,15 @@ export { fallbackBlockExporter } from "./pptx/block-exporters/fallback"; export { FIDELITY_POLICY } from "./fidelity/fidelity-policy"; export { calculateContentParity, rawText } from "./fidelity/content-parity"; export { planBlockRepresentation, countRepresentation } from "./fidelity/representation-planner"; +export type { PlannerInput } from "./fidelity/representation-planner"; export { buildFidelityReport, fidelityStatus } from "./fidelity/fidelity-report"; -export { renderDiagramSvg, normalizeDiagram } from "./fidelity/svg/svg-diagram"; -export { renderSnapshotSvg } from "./fidelity/svg/svg-snapshot"; - +export type { BuildFidelityReportInput } from "./fidelity/fidelity-report"; export type { - FidelityStatus, FidelityBlockReport, FidelityHardRules, + FidelityStatus, PptxFidelityPolicy, } from "./fidelity/fidelity-types"; -export type { PlannerInput } from "./fidelity/representation-planner"; -export type { BuildFidelityReportInput } from "./fidelity/fidelity-report"; -export { runExportPreflight } from "./export-preflight"; -export { ExportDialog } from "./export-dialog"; \ No newline at end of file +export { runExportPreflight, compareSnapshots } from "./export-preflight"; +export { ExportDialog } from "./export-dialog"; diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/chart.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/chart.ts index 506fd3d..96b6517 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/chart.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/chart.ts @@ -1,77 +1,197 @@ +// export/pptx/block-exporters/chart.ts +// +// PPTX chart exporter that consumes the canonical ResolvedChartSpec from the +// snapshot resolver. Charts are exported as vector SVG images rendered from the +// SAME layout engine the web presenter uses (`renderChartToSvg`), placed at the +// full block frame — the web chart is an <svg viewBox="0 0 560 300"> filling the +// frame, so an embedded copy is pixel-identical. +// +// Native PowerPoint charts are deliberately NOT used: pptxgenjs/PowerPoint +// cannot reproduce the web chart's per-bar highlight color, exact plot-area +// geometry (no plot-margin API), the solid baseline under dashed gridlines, or +// the top-down category order of horizontal bars. The SVG is the only path that +// is 100% faithful to the web. + import type { PptxBlockExport, PptxBlockExporter, PptxExportContext, } from "../../export-types"; +import type { Block, ChartContent } from "../../../deck/types"; +import { chartSpecFromContent } from "../../../deck/chart-spec"; +import { exportFrameOf, frameErrorIssue } from "../export-utils"; +import type { ResolvedChartSpec } from "../../snapshot"; +import { resolveChartSpecForBlock } from "../../snapshot"; +import { renderChartToSvg } from "../../fidelity/svg/svg-chart"; -interface ChartDataPoint { - label: string; - value: number; -} +/** + * The web chart SVG has a fixed 560x300 viewBox letterboxed inside its block + * frame (preserveAspectRatio meet). The exported element is the largest 560:300 + * box that fits in the frame, centered — the visible drawing region. + */ +const CHART_ASPECT = 560 / 300; -interface ChartBlock { - id: string; - type: "chart"; - chartType?: string; - data?: ChartDataPoint[]; - content?: { type?: string; title?: string; values?: ChartDataPoint[] }; - title?: string; - x?: number; - y?: number; - w?: number; - h?: number; - frame?: { x?: number; y?: number; w?: number; h?: number }; +function chartContainedFrame(frame: { x: number; y: number; w: number; h: number }): { + x: number; + y: number; + w: number; + h: number; +} { + const frameAspect = frame.w / frame.h; + let w = frame.w; + let h = frame.h; + if (frameAspect > CHART_ASPECT) { + w = frame.h * CHART_ASPECT; + } else { + h = frame.w / CHART_ASPECT; + } + return { x: frame.x + (frame.w - w) / 2, y: frame.y + (frame.h - h) / 2, w, h }; } -const CHART_TYPE_MAP: Record<string, string> = { - bar: "bar", - "bar-horizontal": "bar", - line: "line", - pie: "pie", - doughnut: "pie", - scatter: "scatter", -}; +/** A chart data point that is definitely well-formed enough to export. */ +function hasRealChartData(content: ChartContent | undefined): boolean { + return Array.isArray(content?.values) && content.values.length > 0; +} export const chartBlockExporter: PptxBlockExporter = { type: "chart", - exportability: "native-editable", + exportability: "hybrid-rasterized", async export(block: unknown, ctx: PptxExportContext): Promise<PptxBlockExport> { - const chartBlock = block as ChartBlock; - const content = chartBlock.content; - const values: ChartDataPoint[] = content?.values ?? chartBlock.data ?? []; - const chartType = content?.type ?? chartBlock.chartType ?? "bar"; - const title = content?.title ?? chartBlock.title ?? ""; - const pptxChartType = CHART_TYPE_MAP[chartType] ?? "bar"; + const chartBlock = block as Block; + const frame = exportFrameOf(chartBlock); - const chartData = [ - { - name: title || "Data", - labels: values.map((point) => point.label), - values: values.map((point) => point.value), - }, - ]; + if (!frame) { + return { + status: "unsupported", + issues: [frameErrorIssue(chartBlock.id, "chart blocks require a resolved frame")], + }; + } + + const content = chartBlock.content as ChartContent | undefined; + + // A "New chart" template block has no real content yet: never export it as + // a genuine chart with placeholder values (A=40, B=60). + if (content?.isTemplate) { + return { + status: "skipped", + issues: [ + { + code: "template-chart-skipped", + severity: "warning", + message: `Chart block "${chartBlock.id}" is an unconfigured "New chart" template and was not exported`, + suggestedFix: "Edit the chart to add real data before exporting", + automaticFixAvailable: false, + }, + ], + }; + } + + // Malformed data (a non-array "values") is a hard error: the block is a + // source chart but cannot produce a semantic chart. This must never fall + // through to a default chart with placeholder data. + if (content && !Array.isArray(content.values)) { + return { + status: "unsupported", + issues: [ + { + code: "chart-no-data", + severity: "error", + message: `Chart block "${chartBlock.id}" has malformed data (expected an array of {label, value}) and was not exported`, + suggestedFix: "Give the chart a valid values array", + automaticFixAvailable: false, + }, + ], + }; + } + + if (!hasRealChartData(content)) { + return { + status: "skipped", + issues: [ + { + code: "chart-no-data", + severity: "warning", + message: `Chart block "${chartBlock.id}" has no data values and was skipped`, + suggestedFix: "Add data values to the chart", + automaticFixAvailable: false, + }, + ], + }; + } + + // ── Canonical spec: THE single source of truth for data + colors. ────── + const chartSpec: ResolvedChartSpec | undefined = resolveChartSpecForBlock(ctx.deck, chartBlock); + if (!chartSpec) { + return { + status: "unsupported", + issues: [ + { + code: "chart-no-data", + severity: "error", + message: `Chart block "${chartBlock.id}" could not be resolved into a semantic chart`, + suggestedFix: "Verify the chart has a valid type, values, and labels", + automaticFixAvailable: false, + }, + ], + }; + } + + // Data parity invariant: the exported spec MUST be the exact content data. + const sourceValues = content!.values; + if (chartSpec.categories.length !== sourceValues.length) { + return { + status: "unsupported", + issues: [ + { + code: "chart-data-mismatch", + severity: "error", + message: `Chart block "${chartBlock.id}" has data mismatch: ${chartSpec.categories.length} categories in spec vs ${sourceValues.length} in source`, + suggestedFix: "Verify chart data integrity", + automaticFixAvailable: false, + }, + ], + }; + } + for (let i = 0; i < sourceValues.length; i++) { + if (chartSpec.series[0]?.values[i] !== sourceValues[i].value) { + return { + status: "unsupported", + issues: [ + { + code: "chart-data-mismatch", + severity: "error", + message: `Chart block "${chartBlock.id}" value mismatch at index ${i}: expected ${sourceValues[i].value} but got ${chartSpec.series[0]?.values[i]}`, + suggestedFix: "Verify chart data integrity", + automaticFixAvailable: false, + }, + ], + }; + } + } + +// ── Render the EXACT web chart (same SVG the presenter draws). ───────── + // The browser chart is an <svg viewBox="0 0 560 300"> filling the block + // frame; preserveAspectRatio meet letterboxes the drawing into the largest + // 560:300 box, which is the visible region. Embedding that same SVG at the + // contained box reproduces the web drawing byte-for-byte: dashed gridlines, + // per-bar highlight color, exact bar geometry, category order and data + // labels like "2.4MB". + const chartFrame = chartContainedFrame(frame); + const svgString = renderChartToSvg(chartSpec); return { - status: "native", + status: "rasterized", issues: [], element: { - type: "chart", - x: chartBlock.x ?? chartBlock.frame?.x ?? 0, - y: chartBlock.y ?? chartBlock.frame?.y ?? 0, - w: chartBlock.w ?? chartBlock.frame?.w ?? ctx.slideWidth * 0.7, - h: chartBlock.h ?? chartBlock.frame?.h ?? ctx.slideHeight * 0.5, + type: "svg", + elementId: chartBlock.id, + ...chartFrame, data: { - chartType: pptxChartType, - data: chartData, - options: { - showTitle: !!title, - title, - showValue: true, - dataLabelPosition: "outEnd", - }, + svg: svgString, + alt: chartSpec.summary || chartSpec.title || "Chart", }, }, }; }, -}; \ No newline at end of file +}; diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/diagram.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/diagram.ts index 6bed345..28244eb 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/diagram.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/diagram.ts @@ -1,22 +1,18 @@ +// export/pptx/block-exporters/diagram.ts + import type { PptxBlockExport, PptxBlockExporter, PptxExportContext, } from "../../export-types"; import { renderDiagramSvg } from "../../fidelity/svg/svg-diagram"; -import { mapThemeColors } from "../pptx-theme"; +import { resolveTheme, hexToPptx } from "../../resolved-theme"; +import { exportFrameOf, frameErrorIssue } from "../export-utils"; +import type { Block } from "../../../deck/types"; -interface DiagramBlock { - id: string; - type: "diagram"; +interface DiagramContent { nodes?: Array<{ id?: string; label: string } | string>; edges?: Array<{ from: string; to: string } | string>; - content?: { nodes?: Array<{ id?: string; label: string } | string>; edges?: Array<{ from: string; to: string } | string> }; - x?: number; - y?: number; - w?: number; - h?: number; - frame?: { x?: number; y?: number; w?: number; h?: number }; } export const diagramBlockExporter: PptxBlockExporter = { @@ -24,27 +20,31 @@ export const diagramBlockExporter: PptxBlockExporter = { exportability: "image-only", async export(block: unknown, ctx: PptxExportContext): Promise<PptxBlockExport> { - const diagramBlock = block as DiagramBlock; - const content = diagramBlock.content; - const nodes = content?.nodes ?? diagramBlock.nodes ?? []; - const edges = content?.edges ?? diagramBlock.edges ?? []; - const x = diagramBlock.x ?? diagramBlock.frame?.x ?? 0; - const y = diagramBlock.y ?? diagramBlock.frame?.y ?? 0; - const w = diagramBlock.w ?? diagramBlock.frame?.w ?? ctx.slideWidth * 0.6; - const h = diagramBlock.h ?? diagramBlock.frame?.h ?? ctx.slideHeight * 0.4; + const diagramBlock = block as Block; + const frame = exportFrameOf(diagramBlock); + if (!frame) { + return { + status: "unsupported", + issues: [frameErrorIssue(diagramBlock.id, "diagram blocks require a resolved frame")], + }; + } + + const content = diagramBlock.content as DiagramContent | undefined; + const nodes = content?.nodes ?? []; + const edges = content?.edges ?? []; - const theme = mapThemeColors(ctx.deck.theme); + const theme = resolveTheme(ctx.deck); const svg = renderDiagramSvg( { nodes, edges }, { - width: Math.max(1, Math.round(w)), - height: Math.max(1, Math.round(h)), + width: Math.max(1, Math.round(frame.w)), + height: Math.max(1, Math.round(frame.h)), colors: { - background: theme.background, - nodeFill: theme.light1, - nodeStroke: theme.accent1, - labelColor: theme.text, - edgeColor: theme.dark2, + background: hexToPptx(theme.tokens.background), + nodeFill: hexToPptx(theme.tokens.surface), + nodeStroke: hexToPptx(theme.tokens.primary), + labelColor: hexToPptx(theme.tokens.foreground), + edgeColor: hexToPptx(theme.tokens.muted), }, } ); @@ -54,12 +54,10 @@ export const diagramBlockExporter: PptxBlockExporter = { issues: [], element: { type: "svg", - x, - y, - w, - h, + elementId: diagramBlock.id, + ...frame, data: { svg, alt: (diagramBlock as { alt?: string }).alt }, }, }; }, -}; +}; \ No newline at end of file diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/fallback.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/fallback.ts index fdce238..c517844 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/fallback.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/fallback.ts @@ -1,30 +1,39 @@ +// export/pptx/block-exporters/fallback.ts + import type { PptxBlockExport, PptxBlockExporter, PptxExportContext, } from "../../export-types"; import { renderSnapshotSvg } from "../../fidelity/svg/svg-snapshot"; +import { exportFrameOf, frameErrorIssue } from "../export-utils"; +import type { Block } from "../../../deck/types"; export const fallbackBlockExporter: PptxBlockExporter = { type: "fallback", exportability: "image-only", async export(block: unknown, ctx: PptxExportContext): Promise<PptxBlockExport> { - const anyBlock = block as Record<string, unknown>; - const blockType = (anyBlock.type as string) ?? "unknown"; - const frame = (anyBlock.frame as { x?: number; y?: number; w?: number; h?: number } | undefined) ?? {}; - const x = (anyBlock.x as number) ?? frame.x ?? 0; - const y = (anyBlock.y as number) ?? frame.y ?? 0; - const w = (anyBlock.w as number) ?? frame.w ?? ctx.slideWidth * 0.5; - const h = (anyBlock.h as number) ?? frame.h ?? ctx.slideHeight * 0.3; + const anyBlock = block as Block; + const frame = exportFrameOf(anyBlock); + if (!frame) { + return { + status: "unsupported", + issues: [frameErrorIssue(anyBlock.id, "fallback blocks require a resolved frame")], + }; + } + const blockType = anyBlock.type ?? "unknown"; const content = anyBlock.content; const text = typeof content === "string" ? content : content ? JSON.stringify(content) : ""; - const alt = (anyBlock.alt as string) ?? (anyBlock.ariaLabel as string) ?? ""; + const alt = anyBlock.alt ?? anyBlock.ariaLabel ?? ""; + + const finalW = Math.max(100, frame.w); + const finalH = Math.max(60, frame.h); const svg = renderSnapshotSvg({ - width: Math.max(1, Math.round(w)), - height: Math.max(1, Math.round(h)), + width: Math.round(finalW), + height: Math.round(finalH), title: blockType, text, alt, @@ -43,12 +52,10 @@ export const fallbackBlockExporter: PptxBlockExporter = { ], element: { type: "svg", - x, - y, - w, - h, + elementId: anyBlock.id, + ...frame, data: { svg, alt }, }, }; }, -}; +}; \ No newline at end of file diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/image.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/image.ts index bd494b4..9fc925d 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/image.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/image.ts @@ -1,55 +1,60 @@ +// export/pptx/block-exporters/image.ts +// +// The image exporter consumes the canonical, pre-resolved asset registry built +// by the single `prepareExport` phase. It performs NO network work of its own: +// if the preparation phase failed to resolve a required image, this exporter +// reports a blocking error (Fidelity First) or a truthful rasterized fallback +// (Editability First) — never a silent omission, and never a re-fetch that +// could contradict what preflight reported. + import type { PptxBlockExport, PptxBlockExporter, PptxExportContext, PptxSlideElement, } from "../../export-types"; -import type { DeckProject } from "../../../deck-types"; -import { embedAsset } from "../pptx-assets"; - -interface ImageContentLike { - assetId?: string; - src?: string; - alt?: string; - fit?: string; -} - -interface ImageBlock { - id: string; - type: "image"; - src?: string; - alt?: string; - content?: ImageContentLike; - x?: number; - y?: number; - w?: number; - h?: number; - frame?: { x?: number; y?: number; w?: number; h?: number }; -} +import { canonicalAssetRef } from "../../../deck/assets"; +import { exportFrameOf, frameErrorIssue } from "../export-utils"; +import { documentUnitToPptxInches } from "../../geometry"; +import { PLACEHOLDER_IMAGE_DATA_URI } from "../pptx-placeholder"; +import { readImageSizeFromDataUri } from "../../image-dimensions"; +import type { Block, ImageBlockContent } from "../../../deck/types"; -function imageGeometry(block: ImageBlock, ctx: PptxExportContext) { +/** + * PPTX sizing box (inches) for an image element, derived from the resolved + * document-pixel frame. pptxgenjs interprets `sizing.w/h` as INCHES when the + * value is < 100 and as EMU otherwise — feeding it document pixels (e.g. 852) + * produced a 0.001"-wide picture. Inches are always the correct unit here. + */ +function sizingBoxInches( + frame: { w: number; h: number }, + ctx: PptxExportContext, +): { w: number; h: number } { return { - x: block.x ?? block.frame?.x ?? 0, - y: block.y ?? block.frame?.y ?? 0, - w: block.w ?? block.frame?.w ?? ctx.slideWidth * 0.5, - h: block.h ?? block.frame?.h ?? ctx.slideHeight * 0.5, + w: documentUnitToPptxInches(frame.w, ctx.slideWidth, ctx.pptxWidth), + h: documentUnitToPptxInches(frame.h, ctx.slideHeight, ctx.pptxHeight), }; } -function placeholderElement(block: ImageBlock, ctx: PptxExportContext): PptxSlideElement { - return { - type: "fallback", - ...imageGeometry(block, ctx), - data: { - text: `[image unavailable: ${block.id}]`, - options: { - fill: { color: "FFF3CD" }, - line: { color: "FFC107", width: 1 }, - fontSize: 12, - color: "856404", - }, - }, - }; +/** + * Intrinsic dimensions for the raster that will be embedded. The bytes are + * authoritative (they match the actual embedded image), so they take priority + * over the manifest record, which can be stale or absent (URL-pasted sources, + * decks saved before upload dimensions were tracked). + */ +function naturalSize( + dataUri: string, + recordedWidth?: number, + recordedHeight?: number, +): { width: number; height: number } | undefined { + const decoded = readImageSizeFromDataUri(dataUri); + if (decoded) { + return decoded; + } + if (recordedWidth && recordedHeight) { + return { width: recordedWidth, height: recordedHeight }; + } + return undefined; } export const imageBlockExporter: PptxBlockExporter = { @@ -57,23 +62,54 @@ export const imageBlockExporter: PptxBlockExporter = { exportability: "native-editable", async export(block: unknown, ctx: PptxExportContext): Promise<PptxBlockExport> { - const imageBlock = block as ImageBlock; - const content = imageBlock.content; - const deckWithAssets = ctx.deck as DeckProject & { assets?: Array<{ id: string; src?: string; alt?: string }> }; - const asset = content?.assetId - ? deckWithAssets.assets?.find((entry) => entry.id === content.assetId) - : undefined; - const src = content?.src ?? imageBlock.src ?? asset?.src ?? ""; - const alt = content?.alt ?? imageBlock.alt ?? asset?.alt ?? ""; - - if (!src) { + const imageBlock = block as Block; + const frame = exportFrameOf(imageBlock); + + // A frame IS required: never place at (0,0) by defaulting w/h. + if (!frame) { + return { + status: "unsupported", + issues: [frameErrorIssue(imageBlock.id, "image blocks require a resolved frame")], + }; + } + + const content = (imageBlock.content as ImageBlockContent | undefined) ?? {}; + const ref = canonicalAssetRef(ctx.deck, imageBlock); + const alt = content.alt ?? imageBlock.alt ?? ""; + const fit = content.fit ?? "cover"; + + const fix = + "Use a local asset or a data: URL so the image can be embedded offline"; + + // Placeholder image block: no source and no asset id. The web renders a + // designed placeholder, so PPTX keeps the visual slot filled with the + // bundled placeholder raster — a truthful fallback, not an omission. + if (!ref) { + return { + status: "rasterized", + issues: [ + { + code: "no-fallback-produced", + severity: "info", + message: `Image block "${imageBlock.id}" has no image source; the bundled placeholder raster was embedded`, + suggestedFix: "Attach a local asset to the image block or use a data: URL", + automaticFixAvailable: true, + }, + ], + element: placeholderElement(imageBlock.id, frame, alt, fit, ctx), + }; + } + + // Orphan: the block references a manifest asset that does not exist. This + // is a real resolution failure, surfaced the same way as a dead URL. + if (ref.orphan) { return { - status: "skipped", + status: "unsupported", issues: [ { code: "image-load-failed", - severity: "warning", - message: `Image block "${imageBlock.id}" has no resolvable source and was skipped`, + severity: "error", + message: `Image block "${imageBlock.id}" references asset "${ref.assetId}" which has no manifest entry; the image cannot be embedded`, suggestedFix: "Attach a local asset to the image block or use a data: URL", automaticFixAvailable: false, }, @@ -81,39 +117,112 @@ export const imageBlockExporter: PptxBlockExporter = { }; } - const assetResult = await embedAsset(src, ctx.assetCache); + const entry = ctx.assetRegistry.get(ref.assetId); + const source = entry?.originalSrc ?? ref.src ?? ""; - if (!assetResult.dataUri) { + if (!source) { return { - status: "substituted", + status: "unsupported", issues: [ { code: "image-load-failed", - severity: "warning", - message: `Image "${src}" could not be loaded; replaced with a placeholder box`, - suggestedFix: "Use a local asset or a data: URL so the image can be embedded offline", + severity: "error", + message: `Image block "${imageBlock.id}" has no resolvable source and cannot be embedded`, + suggestedFix: fix, automaticFixAvailable: false, }, ], - element: placeholderElement(imageBlock, ctx), }; } - const geometry = imageGeometry(imageBlock, ctx); - return { - status: "native", - issues: [], - element: { + if (entry && entry.status === "ready" && entry.resolvedDataUri) { + const natural = naturalSize(entry.resolvedDataUri, entry.width, entry.height); + const element: PptxSlideElement = { type: "image", - ...geometry, + elementId: imageBlock.id, + ...frame, data: { - dataUri: assetResult.dataUri, + dataUri: entry.resolvedDataUri, alt, + naturalWidth: natural?.width, + naturalHeight: natural?.height, options: { - sizing: { type: "contain", w: geometry.w, h: geometry.h }, + sizing: { + type: fit === "cover" ? "cover" : "contain", + ...sizingBoxInches(frame, ctx), + }, + margin: 0, }, }, - }, + }; + return { status: "native", issues: [], element }; + } + + // The preparation phase failed to resolve this required image. + const reason = + entry?.error ?? "network error, CORS restriction, or missing asset"; + const base = { + code: "image-load-failed" as const, + automaticFixAvailable: false as const, + }; + + // Fidelity First never ships a successful export with a placeholder in + // place of a real image: an unresolved required image is a blocking error. + if (ctx.config.mode === "fidelity-first") { + return { + status: "unsupported", + issues: [ + { + ...base, + severity: "error", + message: `Image "${source}" (block "${imageBlock.id}") could not be loaded: ${reason}`, + suggestedFix: fix, + }, + ], + }; + } + + // Editability-first: keep the visual slot filled with the bundled + // placeholder raster so the image still "appears" in PPTX. + return { + status: "rasterized", + issues: [ + { + ...base, + severity: "warning", + message: `Image "${source}" (block "${imageBlock.id}") could not be loaded: ${reason}; a bundled placeholder image was embedded in its place`, + suggestedFix: fix, + }, + ], + element: placeholderElement(imageBlock.id, frame, alt, fit, ctx), }; }, -}; \ No newline at end of file +}; + +function placeholderElement( + elementId: string, + frame: { x: number; y: number; w: number; h: number }, + alt: string, + fit: string, + ctx: PptxExportContext, +): PptxSlideElement { + const natural = readImageSizeFromDataUri(PLACEHOLDER_IMAGE_DATA_URI); + return { + type: "image", + elementId, + ...frame, + data: { + dataUri: PLACEHOLDER_IMAGE_DATA_URI, + alt, + naturalWidth: natural?.width, + naturalHeight: natural?.height, + options: { + sizing: { + type: fit === "cover" ? "cover" : "contain", + ...sizingBoxInches(frame, ctx), + }, + margin: 0, + }, + }, + }; +} \ No newline at end of file diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/index.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/index.ts index f20ddc4..7b170ad 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/index.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/index.ts @@ -8,8 +8,8 @@ import { calloutBlockExporter, citationBlockExporter, metricBlockExporter, - processBlockExporter, } from "./text"; +import { processBlockExporter } from "./process"; import { imageBlockExporter } from "./image"; import { shapeBlockExporter } from "./shape"; import { tableBlockExporter } from "./table"; diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/process.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/process.ts new file mode 100644 index 0000000..a1ea3d4 --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/process.ts @@ -0,0 +1,236 @@ +// export/pptx/block-exporters/process.ts +// +// Native PPTX export for `process` blocks. A process is rendered as editable +// PowerPoint shapes with real text runs — never a screenshot. The layout is the +// web renderer's vertical numbered list (render/BlockRenderer.tsx ProcessBlock + +// styles.css `.block-process`): +// +// 01 <title> <- code-font index, bold, secondary color +// <detail> <- muted detail below the title +// +// process node -> editable text rows stacked vertically inside the frame +// step index -> its own "01".."0N" column on the left +// node title/body -> styled text runs (bold title + muted detail) +// +// The old horizontal "card row + right-arrow connectors" layout was removed +// because it did not match the webapp; connectors would have to be re-added +// only if a future web design uses them. Fallback hierarchy honoured: +// 1) native editable primitives, 2) SVG, 3) raster, 4) fatal only when the +// source content itself is unavailable. A missing frame is a geometry error and +// returns "unsupported" — never a (0,0) placeholder. + +import type { + ExportIssue, + PptxBlockExport, + PptxBlockExporter, + PptxExportContext, + PptxSlideElement, +} from "../../export-types"; +import { resolveTheme, hexToPptx } from "../../resolved-theme"; +import { + browserTypographyFor, + exportFrameOf, + fontSizeToPpt, + frameErrorIssue, + pptFontFor, +} from "../export-utils"; +import { fontSizeFromCqw } from "../../geometry"; +import type { Block } from "../../../deck/types"; + +interface ProcessStepContent { + title?: unknown; + detail?: unknown; +} + +interface ProcessContent { + steps?: ProcessStepContent[]; +} + +const INDEX_GLYPH_FACTOR = 1.1; +const BODY_GAP_PX = 12.8; +const ROW_GAP_PX = 11.2; +const INDEX_TOP_PAD_PX = 3.2; + +function stepTitle(step: ProcessStepContent | undefined, index: number): string { + if (!step) return `Step ${index + 1}`; + const title = typeof step.title === "string" ? step.title : ""; + return title || `Step ${index + 1}`; +} + +function stepDetail(step: ProcessStepContent | undefined): string { + return typeof step?.detail === "string" ? step.detail : ""; +} + +async function exportProcessBlock( + block: unknown, + ctx: PptxExportContext, +): Promise<PptxBlockExport> { + const processBlock = block as Block; + const frame = exportFrameOf(processBlock); + const issues: ExportIssue[] = []; + if (!frame) { + return { + status: "unsupported", + issues: [frameErrorIssue(processBlock.id, "process blocks require a resolved frame")], + }; + } + + const theme = resolveTheme(ctx.deck); + const content = processBlock.content as ProcessContent | undefined; + const steps = Array.isArray(content?.steps) ? content.steps : []; + + const elements: PptxSlideElement[] = []; + const bodyFont = pptFontFor(theme.typography.bodyFont, ctx); + const codeFont = pptFontFor(theme.typography.codeFont, ctx); + const typography = browserTypographyFor(processBlock, frame.w); + + // Web `.process-step` typography: index < title > detail. Each size is an + // independent cqw clamp from styles.css (not derived from the title): + // title -> clamp(13px,1.6cqw,18px), index -> clamp(11px,1.4cqw,15px), + // detail -> clamp(12px,1.5cqw,16px). + const titlePx = typography.fontSizePx; + const clampCqw = (factor: number, min: number, max: number): number => + Math.round(fontSizeFromCqw(factor, min, max, frame.w) * 100) / 100; + const detailPx = clampCqw(1.5, 12, 16); + const indexPx = clampCqw(1.4, 11, 15); + const titlePt = fontSizeToPpt(titlePx, ctx); + const detailPt = fontSizeToPpt(detailPx, ctx); + const indexPt = fontSizeToPpt(indexPx, ctx); + + if (steps.length === 0) { + // Content present but no steps: still preserve the block as an editable + // text shape rather than silently dropping it. + const fallbackText = + typeof processBlock.content === "string" + ? processBlock.content + : processBlock.alt || "Process"; + elements.push({ + type: "text", + elementId: processBlock.id, + x: frame.x, + y: frame.y, + w: frame.w, + h: frame.h, + data: { + text: fallbackText, + options: { + fontFace: bodyFont, + fontSize: titlePt, + bold: true, + color: hexToPptx(theme.tokens.foreground), + align: "left", + valign: "top", + wrap: true, + breakLine: true, + autoFit: false, + margin: 0, + }, + }, + }); + } else { + // Web `.block-process` is a flex column at natural height, packed from the + // top (no frame-filling stretch): rows stack with `gap: 0.7em` (11.2px) + // and each row is a flex row with `gap: 0.8em` (12.8px) between the index + // glyphs and the body. The index has `padding-top: 0.2em` (3.2px). + const indexW = indexPx * INDEX_GLYPH_FACTOR; + const bodyX = frame.x + indexW + BODY_GAP_PX; + const bodyW = Math.max(40, frame.w - indexW - BODY_GAP_PX); + const lineH = (px: number) => px * typography.lineHeight; + let y = frame.y; + + for (let i = 0; i < steps.length; i++) { + const title = stepTitle(steps[i], i); + const detail = stepDetail(steps[i]); + const titleH = lineH(titlePx); + const detailH = detail ? lineH(detailPx) : 0; + const rowH = Math.max(lineH(indexPx), titleH + detailH); + + // Index column: "01".."0N" in the code font, bold, secondary color. + elements.push({ + type: "text", + elementId: processBlock.id, + x: frame.x, + y: y + INDEX_TOP_PAD_PX, + w: indexW, + h: rowH, + data: { + text: String(i + 1).padStart(2, "0"), + options: { + fontFace: codeFont, + fontSize: indexPt, + bold: true, + color: hexToPptx(theme.tokens.secondary), + align: "left", + valign: "top", + wrap: true, + breakLine: true, + autoFit: false, + margin: 0, + lineSpacingMultiple: typography.lineHeight, + }, + }, + }); + + // Body: bold title run, then a muted detail run on the next line. + const runs: Array<{ text: string; options: Record<string, unknown> }> = [ + { + text: title, + options: { + fontFace: bodyFont, + fontSize: titlePt, + bold: true, + color: hexToPptx(theme.tokens.foreground), + }, + }, + ]; + if (detail) { + runs.push({ + text: detail, + options: { + fontFace: bodyFont, + fontSize: detailPt, + bold: false, + color: hexToPptx(theme.tokens.muted), + breakLine: true, + }, + }); + } + + elements.push({ + type: "text", + elementId: processBlock.id, + x: bodyX, + y, + w: bodyW, + h: rowH, + data: { + text: runs, + options: { + align: "left", + valign: "top", + wrap: true, + breakLine: true, + autoFit: false, + margin: 0, + lineSpacingMultiple: typography.lineHeight, + }, + }, + }); + + y += rowH + ROW_GAP_PX; + } + } + + return { + status: "native", + issues, + element: elements[0], + elements, + }; +} + +export const processBlockExporter: PptxBlockExporter = { + type: "process", + exportability: "native-editable", + export: exportProcessBlock, +}; diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/shape.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/shape.ts index ee2a64b..d7c4d51 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/shape.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/shape.ts @@ -1,21 +1,18 @@ +// export/pptx/block-exporters/shape.ts + import type { PptxBlockExport, PptxBlockExporter, PptxExportContext, } from "../../export-types"; +import { exportFrameOf, frameErrorIssue } from "../export-utils"; +import type { Block } from "../../../deck/types"; interface ShapeBlock { - id: string; - type: "shape"; shapeType?: string; fill?: string; stroke?: string; strokeWidth?: number; - x?: number; - y?: number; - w?: number; - h?: number; - frame?: { x?: number; y?: number; w?: number; h?: number }; } const SHAPE_MAP: Record<string, string> = { @@ -34,25 +31,32 @@ export const shapeBlockExporter: PptxBlockExporter = { exportability: "native-editable", async export(block: unknown, ctx: PptxExportContext): Promise<PptxBlockExport> { - const shapeBlock = block as ShapeBlock; - const pptxShape = SHAPE_MAP[shapeBlock.shapeType ?? "rectangle"] ?? "rect"; + const shapeBlock = block as Block; + const frame = exportFrameOf(shapeBlock); + if (!frame) { + return { + status: "unsupported", + issues: [frameErrorIssue(shapeBlock.id, "shape blocks require a resolved frame")], + }; + } + + const props = shapeBlock.content as ShapeBlock | undefined; + const pptxShape = SHAPE_MAP[props?.shapeType ?? "rectangle"] ?? "rect"; return { status: "native", issues: [], element: { type: "shape", - x: shapeBlock.x ?? shapeBlock.frame?.x ?? 0, - y: shapeBlock.y ?? shapeBlock.frame?.y ?? 0, - w: shapeBlock.w ?? shapeBlock.frame?.w ?? 2, - h: shapeBlock.h ?? shapeBlock.frame?.h ?? 2, + elementId: shapeBlock.id, + ...frame, data: { shape: pptxShape, options: { - fill: { color: shapeBlock.fill?.replace("#", "") ?? "FFFFFF" }, + fill: { color: props?.fill?.replace("#", "") ?? "FFFFFF" }, line: { - color: shapeBlock.stroke?.replace("#", "") ?? "000000", - width: shapeBlock.strokeWidth ?? 1, + color: props?.stroke?.replace("#", "") ?? "000000", + width: props?.strokeWidth ?? 1, }, }, }, diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/table.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/table.ts index 550af9f..f241a4f 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/table.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/table.ts @@ -1,8 +1,12 @@ +// export/pptx/block-exporters/table.ts + import type { PptxBlockExport, PptxBlockExporter, PptxExportContext, } from "../../export-types"; +import { exportFrameOf, frameErrorIssue } from "../export-utils"; +import type { Block } from "../../../deck/types"; interface TableCell { text: string; @@ -11,16 +15,9 @@ interface TableCell { fill?: string; } -interface TableBlock { - id: string; - type: "table"; +interface TableBlockContent { rows: TableCell[][]; headerRow?: boolean; - x?: number; - y?: number; - w?: number; - h?: number; - frame?: { x?: number; y?: number; w?: number; h?: number }; } export const tableBlockExporter: PptxBlockExporter = { @@ -28,13 +25,24 @@ export const tableBlockExporter: PptxBlockExporter = { exportability: "native-editable", async export(block: unknown, ctx: PptxExportContext): Promise<PptxBlockExport> { - const tableBlock = block as TableBlock; + const tableBlock = block as Block; + const frame = exportFrameOf(tableBlock); + if (!frame) { + return { + status: "unsupported", + issues: [frameErrorIssue(tableBlock.id, "table blocks require a resolved frame")], + }; + } + + const content = tableBlock.content as TableBlockContent | undefined; + const rows = content?.rows ?? []; + const headerRow = content?.headerRow ?? false; - const pptxRows = tableBlock.rows.map((row, rowIdx) => + const pptxRows = rows.map((row, rowIdx) => row.map((cell) => ({ text: cell.text, options: { - bold: cell.bold ?? (tableBlock.headerRow && rowIdx === 0), + bold: cell.bold ?? (headerRow && rowIdx === 0), color: cell.color?.replace("#", "") ?? "000000", fill: { color: cell.fill?.replace("#", "") ?? "FFFFFF" }, valign: "middle", @@ -48,10 +56,8 @@ export const tableBlockExporter: PptxBlockExporter = { issues: [], element: { type: "table", - x: tableBlock.x ?? tableBlock.frame?.x ?? 0, - y: tableBlock.y ?? tableBlock.frame?.y ?? 0, - w: tableBlock.w ?? tableBlock.frame?.w ?? ctx.slideWidth * 0.8, - h: tableBlock.h ?? tableBlock.frame?.h ?? ctx.slideHeight * 0.5, + elementId: tableBlock.id, + ...frame, data: { rows: pptxRows, options: { diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/text.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/text.ts index 0c79b82..10dcb66 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/text.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/text.ts @@ -1,3 +1,8 @@ +// export/pptx/block-exporters/text.ts +// +// PPTX text exporter that uses the resolved theme for colors and fonts. +// This ensures typography parity between web and PPTX. + import type { ExportIssue, PptxBlockExport, @@ -5,72 +10,152 @@ import type { PptxExportContext, PptxSlideElement, } from "../../export-types"; -import { checkFontCompatibility } from "../pptx-fonts"; +import { + browserTypographyFor, + estimateTextHeightPx, + exportFrameOf, + fontSizeToPpt, + frameErrorIssue, + pptFontFor, + type BrowserTypography, +} from "../export-utils"; +import { resolveTheme, hexToPptx } from "../../resolved-theme"; +import type { Block } from "../../../deck/types"; const MAX_TEXT_LENGTH = 4000; -interface BlockGeometry { - x?: number; - y?: number; - w?: number; - h?: number; - frame?: { x?: number; y?: number; w?: number; h?: number }; +interface BlockLike { + id: string; + type: string; } -interface TextBlock extends BlockGeometry { - id: string; - type: "text" | "heading"; - content?: unknown; - fontFamily?: string; - fontSize?: number; - fontWeight?: string; - color?: string; - textAlign?: string; +/** Text content arrived as a plain string, array of lines, or {text|value}. */ +function stringContent(block: { content?: unknown }): string { + if (typeof block.content === "string") return block.content; + if (Array.isArray(block.content)) { + return block.content.filter((item): item is string => typeof item === "string").join("\n"); + } + if (block.content && typeof block.content === "object") { + const obj = block.content as Record<string, unknown>; + if (typeof obj.text === "string") return obj.text; + if (typeof obj.value === "string") return obj.value; + if (obj.label && typeof obj.label === "string") return obj.label; + } + return ""; } -function geometry(block: BlockGeometry, ctx: PptxExportContext, defaultW: number, defaultH: number) { - return { - x: block.x ?? block.frame?.x ?? 0, - y: block.y ?? block.frame?.y ?? 0, - w: block.w ?? block.frame?.w ?? defaultW, - h: block.h ?? block.frame?.h ?? defaultH, - }; +/** + * Map a web letter-spacing (em units) to PPTX charSpacing (points), using the + * exact same document-px -> point conversion as fontSizeToPpt so the exported + * tracking matches the browser proportionally. + */ +function charSpacingOf( + typography: BrowserTypography, + fontPx: number, + ctx: PptxExportContext, +): number | undefined { + const em = typography.letterSpacingEm ?? 0; + if (!em) return undefined; + const sign = em < 0 ? -1 : 1; + return Math.round(fontSizeToPpt(fontPx * Math.abs(em), ctx) * sign * 100) / 100; } -function textElement( +/** + * Resolve a text block into a PPTX text element with geometry and typography + * DERIVED from the document (Phase 5/7). Never default-features a missing + * frame to (0,0): a frame-less block is a geometry error, not an invisible + * top-left text box. + */ +function buildTextElement( text: string, - block: BlockGeometry, + block: unknown, ctx: PptxExportContext, - options: Record<string, unknown> = {} -): PptxSlideElement { + containerWidthPx: number, + extra: Record<string, unknown> = {}, +): PptxSlideElement | null { + const frame = exportFrameOf(block as Block); + if (!frame) return null; + + const b = block as Block; + const typography = browserTypographyFor(b, containerWidthPx > 0 ? containerWidthPx : frame.w); + const fontPx = (extra.fontSizePx as number) ?? typography.fontSizePx; + const fontSizePt = fontSizeToPpt(fontPx, ctx); + + // Use resolved theme for colors and fonts + const theme = resolveTheme(ctx.deck); + const explicitFont = (b as { fontFamily?: string }).fontFamily ?? ""; + // Headings and the metric value render in the theme heading font on the web + // (BlockRenderer styleFrom + styles.css), so they must not use the body font. + const isHeadingLike = b.type === "heading" || b.type === "metric"; + const webFont = explicitFont || (isHeadingLike ? theme.typography.headingFont : theme.typography.bodyFont); + const fontFace = pptFontFor(webFont, ctx); + + const style = b.style ?? {}; + const align = (extra.textAlign as string) ?? (style as { align?: string }).align ?? "left"; + const valign = (extra.valign as string) ?? "top"; + + // Resolve color from theme tokens (web truth = styles.css): + // - citations are explicitly muted (`.block-citation`). + // - the meta variant is foreground at 75% opacity; muted approximates it. + // - callouts and kickers inherit the base foreground color. + let color = theme.tokens.foreground; + if (b.type === "citation") { + color = theme.tokens.muted; + } else if (style.variant === "meta") { + color = theme.tokens.muted; + } + + const charSpacing = charSpacingOf(typography, fontPx, ctx); + const options: Record<string, unknown> = { + fontFace, + fontSize: fontSizePt, + bold: (extra.bold as boolean) ?? typography.bold, + italic: (extra.italic as boolean) ?? typography.italic, + color: hexToPptx(color), + align, + valign, + wrap: true, + breakLine: true, + autoFit: false, + margin: 0, + lineSpacingMultiple: typography.lineHeight, + breakMustFit: true, + }; + if (charSpacing !== undefined) options.charSpacing = charSpacing; + + // Kicker blocks render `text-transform: uppercase` on the web (styleFrom); + // the exported text must carry the same casing. + const outText = style.variant === "kicker" ? text.toUpperCase() : text; + return { type: "text", - ...geometry(block, ctx, ctx.slideWidth * 0.8, 1), - data: { text, options }, + elementId: b.id, + x: frame.x, + y: frame.y, + w: frame.w, + h: frame.h, + data: { + text: outText, + options, + }, }; } -function stringContent(block: { content?: unknown }): string { - return typeof block.content === "string" ? block.content : ""; +function frameIssueIfMissing(block: BlockLike): ExportIssue | null { + const frame = exportFrameOf(block as Block); + if (frame) return null; + return frameErrorIssue(block.id, "text blocks require a resolved frame"); } async function exportTextBlock(block: unknown, ctx: PptxExportContext): Promise<PptxBlockExport> { - const textBlock = block as TextBlock; + const textBlock = block as BlockLike & { content?: unknown }; const text = stringContent(textBlock); - const fontFamily = textBlock.fontFamily ?? "Arial"; const issues: ExportIssue[] = []; + const frame = exportFrameOf(block as Block); + const containerWidth = frame?.w ?? ctx.slideWidth; - const fontWarning = checkFontCompatibility(fontFamily); - if (fontWarning) { - ctx.fontWarnings.push(fontWarning); - issues.push({ - code: "missing-font", - severity: "warning", - message: `Font "${fontFamily}" is not a PowerPoint-safe font and may be substituted with ${fontWarning.substituteFont}`, - suggestedFix: `Use a PPTX-safe font like ${fontWarning.substituteFont}`, - automaticFixAvailable: false, - }); - } + const missing = frameIssueIfMissing(textBlock); + if (missing) return { status: "unsupported", issues: [missing] }; if (text.length > MAX_TEXT_LENGTH) { issues.push({ @@ -81,125 +166,252 @@ async function exportTextBlock(block: unknown, ctx: PptxExportContext): Promise< }); } + const element = buildTextElement(text, block, ctx, containerWidth, { + textAlign: (block as { textAlign?: string }).textAlign, + }); + return { - status: "native", - issues, - element: textElement(text, textBlock, ctx, { - fontFace: fontFamily, - fontSize: textBlock.fontSize ?? 18, - bold: textBlock.fontWeight === "bold", - color: textBlock.color?.replace("#", "") ?? "000000", - align: textBlock.textAlign ?? "left", - valign: "top", - wrap: true, - }), + status: element ? "native" : "unsupported", + issues: element ? issues : [...issues, frameErrorIssue(textBlock.id, "could not resolve geometry")], + element: element ?? undefined, }; } async function exportBulletsBlock(block: unknown, ctx: PptxExportContext): Promise<PptxBlockExport> { - const bulletsBlock = block as BlockGeometry & { content?: unknown }; + const bulletsBlock = block as BlockLike & { content?: unknown }; const lines = Array.isArray(bulletsBlock.content) ? bulletsBlock.content.filter((line): line is string => typeof line === "string") : []; - const text = lines.map((line) => `• ${line}`).join("\n"); + const frame = exportFrameOf(bulletsBlock as Block); + const missing = frameIssueIfMissing(bulletsBlock); + if (missing) return { status: "unsupported", issues: [missing] }; + + const b = bulletsBlock as Block; + const typography = browserTypographyFor(b, frame?.w ?? ctx.slideWidth); + const fontSizePt = fontSizeToPpt(typography.fontSizePx, ctx); + const theme = resolveTheme(ctx.deck); + const fontFace = pptFontFor(theme.typography.bodyFont, ctx); + + // Web truth (styles.css `.block-bullets`): flex column with `gap: 0.4em` + // (6.4px at 16px) and `padding-left: 1.1em` (17.6px); `li::marker` uses the + // theme secondary while the text stays foreground. The 16px font-size comes + // from browserTypographyFor (the list inherits the body font). We reproduce + // the padding by insetting the element, the inter-item gap with a paragraph + // space-after on every line but the last, and the marker as a secondary run. + const bulletColor = hexToPptx(theme.tokens.secondary); + const textColor = hexToPptx(theme.tokens.foreground); + const gapPt = fontSizeToPpt(6.4, ctx); + const runs = lines.flatMap((line, index) => { + const isLast = index === lines.length - 1; + const runsForLine: Array<{ text: string; options: Record<string, unknown> }> = [ + { text: "• ", options: { fontFace, fontSize: fontSizePt, bold: false, color: bulletColor } }, + { + text: line, + options: { + fontFace, + fontSize: fontSizePt, + bold: false, + color: textColor, + ...(isLast ? {} : { paraSpaceAfter: gapPt }), + }, + }, + ]; + if (!isLast) { + runsForLine.push({ text: "", options: { fontFace, fontSize: fontSizePt, breakLine: true } }); + } + return runsForLine; + }); + const paddingLeftPx = 17.6; + const element: PptxSlideElement = { + type: "text", + elementId: bulletsBlock.id, + x: frame!.x + paddingLeftPx, + y: frame!.y, + w: Math.max(20, frame!.w - paddingLeftPx), + h: frame!.h, + data: { + text: runs, + options: { + align: "left", + valign: "top", + wrap: true, + breakLine: true, + autoFit: false, + margin: 0, + lineSpacingMultiple: typography.lineHeight, + }, + }, + }; return { status: "native", issues: [], - element: textElement(text, bulletsBlock, ctx, { - fontFace: "Arial", - fontSize: 16, - color: "333333", - align: "left", - valign: "top", - wrap: true, - }), + element, }; } async function exportCalloutBlock(block: unknown, ctx: PptxExportContext): Promise<PptxBlockExport> { - const calloutBlock = block as BlockGeometry & { content?: unknown }; + const calloutBlock = block as BlockLike & { content?: unknown }; + const frame = exportFrameOf(calloutBlock as Block); + const missing = frameIssueIfMissing(calloutBlock); + if (missing) return { status: "unsupported", issues: [missing] }; + + const b = calloutBlock as Block; + const theme = resolveTheme(ctx.deck); + const typography = browserTypographyFor(b, frame?.w ?? ctx.slideWidth); + const fontPx = typography.fontSizePx; + + // Web truth (styles.css `.block-callout`): a 3px secondary left border with + // `padding: 0.4em 0 0.4em 0.8em` (top/bottom and left, right = 0) and top + // text alignment. The paddings scale with the callout's own font-size. + const borderW = 3; + const padTop = 0.4 * fontPx; + const padLeft = 0.8 * fontPx; + + const accentBar: PptxSlideElement = { + type: "shape", + elementId: calloutBlock.id, + x: frame!.x, + y: frame!.y, + w: borderW, + h: frame!.h, + data: { + shape: "rect", + options: { + fill: { color: hexToPptx(theme.tokens.secondary) }, + line: { color: hexToPptx(theme.tokens.secondary), width: 0 }, + }, + }, + }; + + const textEl = buildTextElement(stringContent(calloutBlock), calloutBlock, ctx, frame?.w ?? ctx.slideWidth, { + valign: "top", + }); + if (!textEl) { + return { status: "unsupported", issues: [frameErrorIssue(calloutBlock.id, "no geometry")] }; + } + const insetEl: PptxSlideElement = { + ...textEl, + x: frame!.x + borderW + padLeft, + y: frame!.y + padTop, + w: Math.max(20, frame!.w - borderW - padLeft), + h: Math.max(20, frame!.h - padTop * 2), + }; return { status: "native", issues: [], - element: textElement(stringContent(calloutBlock), calloutBlock, ctx, { - fontFace: "Arial", - fontSize: 18, - bold: true, - color: "1F2937", - align: "left", - valign: "middle", - wrap: true, - }), + element: textEl, + elements: [accentBar, insetEl], }; } async function exportCitationBlock(block: unknown, ctx: PptxExportContext): Promise<PptxBlockExport> { - const citationBlock = block as BlockGeometry & { content?: unknown }; + const citationBlock = block as BlockLike & { content?: unknown }; + const frame = exportFrameOf(citationBlock as Block); + const missing = frameIssueIfMissing(citationBlock); + if (missing) return { status: "unsupported", issues: [missing] }; + const element = buildTextElement(stringContent(citationBlock), citationBlock, ctx, frame?.w ?? ctx.slideWidth, {}); return { - status: "native", - issues: [], - element: textElement(stringContent(citationBlock), citationBlock, ctx, { - fontFace: "Arial", - fontSize: 12, - italic: true, - color: "6B7280", - align: "left", - valign: "top", - wrap: true, - }), + status: element ? "native" : "unsupported", + issues: element ? [] : [frameErrorIssue(citationBlock.id, "no geometry")], + element: element ?? undefined, }; } async function exportMetricBlock(block: unknown, ctx: PptxExportContext): Promise<PptxBlockExport> { - const metricBlock = block as BlockGeometry & { + const metricBlock = block as BlockLike & { content?: { value?: unknown; label?: unknown; delta?: unknown }; }; const content = metricBlock.content; const value = typeof content?.value === "string" ? content.value : ""; const label = typeof content?.label === "string" ? content.label : ""; const delta = typeof content?.delta === "string" ? content.delta : ""; - const text = [value, label, delta].filter(Boolean).join("\n"); + const frame = exportFrameOf(metricBlock as Block); + const missing = frameIssueIfMissing(metricBlock); + if (missing) return { status: "unsupported", issues: [missing] }; - return { - status: "native", - issues: [], - element: textElement(text, metricBlock, ctx, { - fontFace: "Arial", - fontSize: 24, - bold: true, - color: "111827", - align: "left", - valign: "middle", - wrap: true, - }), - }; -} + const b = metricBlock as Block; + const theme = resolveTheme(ctx.deck); + const headingFontFace = pptFontFor(theme.typography.headingFont, ctx); + const bodyFontFace = pptFontFor(theme.typography.bodyFont, ctx); -async function exportProcessBlock(block: unknown, ctx: PptxExportContext): Promise<PptxBlockExport> { - const processBlock = block as BlockGeometry & { - content?: { steps?: Array<{ title?: unknown; detail?: unknown }> }; - }; - const steps = processBlock.content?.steps ?? []; - const text = steps - .map((step, index) => { - const title = typeof step.title === "string" ? step.title : ""; - const detail = typeof step.detail === "string" && step.detail ? ` \u2014 ${step.detail}` : ""; - return `${index + 1}. ${title}${detail}`; - }) - .join("\n"); + // Web truth (styles.css .block-metric): + // value -> heading font, clamp(64px,9cqw,128px), weight 400, primary, -0.03em + // label -> clamp(13px,1.8cqw,20px), muted, margin-top 0.4em (0.4 * label size) + // delta -> weight 700, clamp(12px,1.6cqw,17px), secondary, margin-top 0.5em + const w = frame?.w ?? ctx.slideWidth; + const valuePx = Math.min(128, Math.max(64, w * 0.09)); + const labelPx = Math.min(20, Math.max(13, w * 0.018)); + const deltaPx = Math.min(17, Math.max(12, w * 0.016)); + const valuePt = fontSizeToPpt(valuePx, ctx); + const labelPt = fontSizeToPpt(labelPx, ctx); + const deltaPt = fontSizeToPpt(deltaPx, ctx); + const valueAfterPt = Math.round(fontSizeToPpt(0.4 * labelPx, ctx) * 100) / 100; + const labelAfterPt = Math.round(fontSizeToPpt(0.5 * deltaPx, ctx) * 100) / 100; + + const runs: Array<{ text: string; options: Record<string, unknown> }> = []; + if (value) { + runs.push({ + text: value, + options: { + fontFace: headingFontFace, + fontSize: valuePt, + bold: false, + color: hexToPptx(theme.tokens.primary), + charSpacing: Math.round(fontSizeToPpt(valuePx * 0.03, ctx) * -100) / 100, // -0.03em + paraSpaceAfter: valueAfterPt, + }, + }); + } + if (label) { + runs.push({ + text: label, + options: { + fontFace: bodyFontFace, + fontSize: labelPt, + bold: false, + color: hexToPptx(theme.tokens.muted), + breakLine: true, + paraSpaceAfter: labelAfterPt, + }, + }); + } + if (delta) { + runs.push({ + text: delta, + options: { + fontFace: bodyFontFace, + fontSize: deltaPt, + bold: true, + color: hexToPptx(theme.tokens.secondary), + breakLine: true, + }, + }); + } + const element: PptxSlideElement = { + type: "text", + elementId: metricBlock.id, + x: frame!.x, + y: frame!.y, + w: frame!.w, + h: frame!.h, + data: { + text: runs, + options: { + align: "left", + valign: "top", + wrap: true, + breakLine: true, + autoFit: false, + margin: 0, + }, + }, + }; return { status: "native", issues: [], - element: textElement(text, processBlock, ctx, { - fontFace: "Arial", - fontSize: 14, - color: "333333", - align: "left", - valign: "top", - wrap: true, - }), + element, }; } @@ -238,9 +450,3 @@ export const metricBlockExporter: PptxBlockExporter = { exportability: "native-editable", export: exportMetricBlock, }; - -export const processBlockExporter: PptxBlockExporter = { - type: "process", - exportability: "image-only", - export: exportProcessBlock, -}; diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/video.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/video.ts index 19e9a2e..b6a8700 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/video.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/block-exporters/video.ts @@ -1,3 +1,5 @@ +// export/pptx/block-exporters/video.ts + import type { PptxBlockExport, PptxBlockExporter, @@ -5,6 +7,8 @@ import type { } from "../../export-types"; import { renderSnapshotSvg } from "../../fidelity/svg/svg-snapshot"; import { mapThemeColors } from "../pptx-theme"; +import { exportFrameOf, frameErrorIssue } from "../export-utils"; +import type { Block } from "../../../deck/types"; interface VideoChapter { title?: unknown; @@ -18,26 +22,21 @@ interface VideoContent { chapter?: VideoChapter; } -interface VideoBlock { - id: string; - type: "video"; - content?: VideoContent; - alt?: string; - ariaLabel?: string; - x?: number; - y?: number; - w?: number; - h?: number; - frame?: { x?: number; y?: number; w?: number; h?: number }; -} - export const videoBlockExporter: PptxBlockExporter = { type: "video", exportability: "poster-with-link", async export(block: unknown, ctx: PptxExportContext): Promise<PptxBlockExport> { - const videoBlock = block as VideoBlock; - const content = videoBlock.content ?? {}; + const videoBlock = block as Block; + const frame = exportFrameOf(videoBlock); + if (!frame) { + return { + status: "unsupported", + issues: [frameErrorIssue(videoBlock.id, "video blocks require a resolved frame")], + }; + } + + const content = (videoBlock.content ?? {}) as VideoContent; const chapter = content.chapter ?? {}; const title = (typeof chapter.title === "string" ? chapter.title : "") || @@ -49,15 +48,11 @@ export const videoBlockExporter: PptxBlockExporter = { .map((point) => `\u2022 ${point}`) .join("\n"); const body = [summary, keyPoints].filter(Boolean).join("\n"); - const x = videoBlock.x ?? videoBlock.frame?.x ?? 0; - const y = videoBlock.y ?? videoBlock.frame?.y ?? 0; - const w = videoBlock.w ?? videoBlock.frame?.w ?? ctx.slideWidth * 0.5; - const h = videoBlock.h ?? videoBlock.frame?.h ?? ctx.slideHeight * 0.3; const theme = mapThemeColors(ctx.deck.theme); const svg = renderSnapshotSvg({ - width: Math.max(1, Math.round(w)), - height: Math.max(1, Math.round(h)), + width: Math.max(1, Math.round(frame.w)), + height: Math.max(1, Math.round(frame.h)), title, text: body, alt: videoBlock.alt ?? videoBlock.ariaLabel ?? "", @@ -82,12 +77,10 @@ export const videoBlockExporter: PptxBlockExporter = { ], element: { type: "svg", - x, - y, - w, - h, + elementId: videoBlock.id, + ...frame, data: { svg, alt }, }, }; }, -}; +}; \ No newline at end of file diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/export-utils.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/export-utils.ts new file mode 100644 index 0000000..1c3bc72 --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/export-utils.ts @@ -0,0 +1,159 @@ +// export/pptx/export-utils.ts +// +// Shared, purely-derived helpers for the PPTX block exporters so no single +// exporter can invent its own coordinate or font conversion (Phases 5/7/10). +// +// RULES enforced here: +// - Geometry comes ONLY from the resolved document frame. Missing/malformed +// frames are errors, never silently placed at (0,0). +// - Font sizes are derived from the browser (document) typography and mapped +// to PPT points via the geometry layer. + +import type { ExportIssue, PptxExportContext } from "../export-types"; +import type { Block } from "../../deck/types"; +import { + browserFontSizeToPptPt, + fontSizeFromCqw, + isUsableFrame, + validateFrame, + type Rect, +} from "../geometry"; +import { resolvePptxFont } from "./pptx-fonts"; + +/** Extract an element rect from a resolved block frame; undefined when invalid. */ +export function exportFrameOf(block: Block): Rect | undefined { + // Prefer the resolved frame (canonical geometry pipeline) over the raw + // persisted frame so slot/flow blocks always export at their real location. + const frame = block.resolvedFrame ?? block.frame; + if (!frame) return undefined; + const rect = { x: frame.x, y: frame.y, w: frame.w, h: frame.h }; + return isUsableFrame(rect) ? rect : undefined; +} + +const FRAME_ERROR_CODES = new Set([ + "missing-frame", +]); + +export function frameErrorIssue(blockId: string, detail: string): ExportIssue { + return { + code: "block-export-failed", + severity: "error", + message: `Block "${blockId}" has invalid geometry: ${detail}. The block was not exported.`, + suggestedFix: "Give the block a valid frame (x, y, w > 0, h > 0) in the editor", + automaticFixAvailable: false, + }; +} + +/** Full geometry validation errors for a block frame (used for diagnostics). */ +export function frameValidation(block: Block): string[] { + const frame = block.frame; + if (!frame) return ["missing frame"]; + return validateFrame({ x: frame.x, y: frame.y, w: frame.w, h: frame.h }); +} + +/** Text block type -> base styling that mirrors render/BlockRenderer.tsx. */ +export interface BrowserTypography { + fontSizePx: number; + lineHeight: number; + bold: boolean; + italic: boolean; + letterSpacingEm?: number; +} + +const clamp = fontSizeFromCqw; + +function clampTo(width: number, factor: number, min: number, max: number): number { + return Math.round(clamp(factor, min, max, width) * 100) / 100; +} + +/** + * Resolve the browser-equivalent typography for a block given its rendered + * container width (in document pixels). Kept in sync with BlockRenderer/styles: + * a block must look the same on the exported slide as in the browser. + */ +export function browserTypographyFor(block: Block, containerWidthPx: number): BrowserTypography { + const style = block.style ?? {}; + const variant = typeof style.variant === "string" ? style.variant : ""; + const level = typeof style.level === "number" ? style.level : 3; + const w = containerWidthPx > 0 ? containerWidthPx : 1; + + switch (block.type) { + case "heading": + if (level === 1) { + return { + fontSizePx: clampTo(w, 4.2, 34, 52), + lineHeight: 1.05, + bold: false, + italic: false, + letterSpacingEm: -0.02, + }; + } + // All headings render as <h2> on the web (BlockRenderer), and h2 keeps + // the browser default font-weight: bold. Only level 1 overrides weight + // to 400, so every other level is bold. + return { fontSizePx: 24, lineHeight: 1.25, bold: true, italic: false }; + case "caption": + return { fontSizePx: 13, lineHeight: 1.5, bold: true, italic: false }; + case "bullets": + // `.block-bullets` inherits the 16px body font-size (styles.css has no + // font-size on the list), so lines are 16px with a normal ~1.2 line box. + return { fontSizePx: 16, lineHeight: 1.2, bold: false, italic: false }; + case "citation": + // `.block-citation` sets no font-style, so citations are NOT italic. + return { fontSizePx: clampTo(w, 1.2, 10, 13), lineHeight: 1.5, bold: false, italic: false }; + case "callout": + return { fontSizePx: clampTo(w, 1.7, 14, 19), lineHeight: 1.5, bold: false, italic: true }; + case "metric": + return { fontSizePx: clampTo(w, 9, 64, 128), lineHeight: 1.0, bold: true, italic: false }; + case "process": + return { fontSizePx: clampTo(w, 1.6, 13, 18), lineHeight: 1.4, bold: true, italic: false }; + default: + if (variant === "kicker") return { fontSizePx: 12, lineHeight: 1.4, bold: true, italic: false, letterSpacingEm: 0.14 }; + if (variant === "meta") return { fontSizePx: 13, lineHeight: 1.5, bold: false, italic: false }; + if (variant === "caption") return { fontSizePx: 13, lineHeight: 1.5, bold: true, italic: false }; + // Inline variant (BlockRenderer styleFrom) fixes 15px; the `.block-callout` + // class clamps instead — that branch is handled by the "callout" case above. + if (variant === "callout") return { fontSizePx: 15, lineHeight: 1.5, bold: false, italic: true }; + return { fontSizePx: clampTo(w, 1.6, 14, 20), lineHeight: 1.55, bold: false, italic: false }; + } +} + +/** Convert a browser font size (px, document units) to PPT points. */ +export function fontSizeToPpt(fontSizePx: number, ctx: PptxExportContext): number { + return browserFontSizeToPptPt(fontSizePx, ctx.slideHeight, ctx.pptxHeight); +} + +/** Resolve the PPT-safe font family for a web font name. */ +export function pptFontFor(webFont: string | undefined, ctx: PptxExportContext): string { + const resolved = resolvePptxFont(webFont ?? "Arial"); + if (webFont && resolved !== webFont) { + ctx.fontWarnings.push({ + fontFamily: webFont, + substituteFont: resolved, + }); + } + return resolved; +} + +/** Shared baseline text frame options (margins disabled, no autofit surprises). */ +export function textFrameOptions( + base: Record<string, unknown>, + ctx: PptxExportContext, +): Record<string, unknown> { + return { + margin: 0, + wrap: true, + breakLine: true, + autoFit: false, + ...base, + }; +} + +/** Estimate a text-height-in-document-px heuristic (mirrors measure.ts). */ +export function estimateTextHeightPx(text: string, fontSizePx: number, widthPx: number): number { + const charsPerLine = Math.max(8, Math.floor(widthPx / (fontSizePx * 0.5))); + const lines = Math.max(1, Math.ceil(text.length / charsPerLine)); + return Math.ceil(lines * fontSizePx * 1.5) + (fontSizePx * 0.4); +} + +export { FRAME_ERROR_CODES }; \ No newline at end of file diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-assets.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-assets.ts index 4a05ab3..35bcc69 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-assets.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-assets.ts @@ -1,4 +1,4 @@ -// starter-components/export/pptx/pptx-assets.ts +// export/pptx/pptx-assets.ts export interface AssetEmbedResult { dataUri: string; @@ -7,12 +7,38 @@ export interface AssetEmbedResult { mimeType: string; } -export async function embedAsset( +export interface EmbedOutcome { + result: AssetEmbedResult; + error?: string; +} + +const FETCH_TIMEOUT_MS = 15000; + +async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timer); + return response; + } catch (err) { + clearTimeout(timer); + throw err; + } +} + +/** + * Fetch one URL into an embeddable data URI, caching by URL so each source is + * resolved at most once per preparation. Unlike the legacy `embedAsset`, this + * variant also reports WHY a resolution failed so preflight can surface an + * actionable, block-specific blocking error instead of a generic one. + */ +export async function embedAssetDetailed( assetUrl: string, cache: Map<string, AssetEmbedResult> -): Promise<AssetEmbedResult> { +): Promise<EmbedOutcome> { if (cache.has(assetUrl)) { - return cache.get(assetUrl)!; + return { result: cache.get(assetUrl)! }; } if (assetUrl.startsWith("data:")) { @@ -21,11 +47,14 @@ export async function embedAsset( mimeType: assetUrl.split(";")[0].split(":")[1] ?? "image/png", }; cache.set(assetUrl, result); - return result; + return { result }; } try { - const response = await fetch(assetUrl); + const response = await fetchWithTimeout(assetUrl, FETCH_TIMEOUT_MS); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } const blob = await response.blob(); const mimeType = blob.type || "image/png"; @@ -40,15 +69,25 @@ export async function embedAsset( const result: AssetEmbedResult = { dataUri, mimeType }; cache.set(assetUrl, result); - return result; - } catch { - return { + return { result }; + } catch (err) { + const error = err instanceof Error ? err.message : "unknown error"; + const empty: AssetEmbedResult = { dataUri: "", mimeType: "image/png", }; + cache.set(assetUrl, empty); + return { result: empty, error }; } } +export async function embedAsset( + assetUrl: string, + cache: Map<string, AssetEmbedResult> +): Promise<AssetEmbedResult> { + return (await embedAssetDetailed(assetUrl, cache)).result; +} + export function embedAssetSync( assetUrl: string, cache: Map<string, AssetEmbedResult> @@ -57,4 +96,4 @@ export function embedAssetSync( return cache.get(assetUrl)!; } return null; -} \ No newline at end of file +} diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-context.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-context.ts index 0d0ad1b..96469e1 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-context.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-context.ts @@ -1,26 +1,36 @@ -import type { PptxExportConfig, FontWarning } from "../export-types"; -import type { DeckProject } from "../../deck-types"; - -export interface PptxExportContextData { - deck: DeckProject; - config: PptxExportConfig; - fontWarnings: FontWarning[]; - assetCache: Map<string, { dataUri: string; width?: number; height?: number; mimeType: string }>; - slideWidth: number; - slideHeight: number; -} +import type { PptxExportConfig, PptxExportContext } from "../export-types"; +import type { DeckProject } from "../../deck/types"; +import { derivePptxSlideSize } from "../geometry"; +import type { PreparedExport } from "../prepare-export"; +/** + * Build the export context. The PPTX slide size is DERIVED from the actual + * document pixel size so the exported aspect ratio always equals the web + * aspect ratio (Phase 4). This context is the only place the document and the + * PPTX geometry relationship is established; individual exporters never invent + * their own mapping. + * + * The context carries the canonical asset registry from the single + * `prepareExport` phase when one was prepared — exporters consume resolved + * bytes from it and never fetch on their own. + */ export function createExportContext( deck: DeckProject, - config: PptxExportConfig -): PptxExportContextData { - const canvas = deck.canvas ?? { width: 13.333, height: 7.5 }; + config: PptxExportConfig, + prepared?: PreparedExport +): PptxExportContext { + const canvas = deck.canvas ?? { width: 1600, height: 900 }; + const slideWidth = canvas.width ?? 1600; + const slideHeight = canvas.height ?? 900; + const pptxSize = derivePptxSlideSize(slideWidth, slideHeight); return { deck, config, fontWarnings: [], - assetCache: new Map(), - slideWidth: canvas.width ?? 13.333, - slideHeight: canvas.height ?? 7.5, + assetRegistry: prepared?.assets ?? new Map(), + slideWidth, + slideHeight, + pptxWidth: pptxSize.width, + pptxHeight: pptxSize.height, }; } \ No newline at end of file diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-exporter.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-exporter.ts index 383d66e..290284a 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-exporter.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-exporter.ts @@ -7,24 +7,25 @@ import type { FidelityReport, PptxBlockExport, PptxExportConfig, + PptxExportContext, PptxExportResult, PptxSlideElement, + PptxTextRun, } from "../export-types"; -import type { DeckProject, DeckSlide } from "../../deck-types"; +import type { DeckProject, DeckSlide, ChartContent } from "../../deck/types"; import { createExportContext } from "./pptx-context"; +import { prepareExport, isPreparedExport, type PreparedExport } from "../prepare-export"; import { getBlockExporter } from "./block-exporters/index"; +import { resolveSlideGeometry, type ResolvedBlockGeometry } from "../../deck/geometry-resolver"; import { verifyPptxArchive } from "./pptx-verifier"; -import { rawText } from "../fidelity/content-parity"; import { FIDELITY_POLICY } from "../fidelity/fidelity-policy"; import { planBlockRepresentation } from "../fidelity/representation-planner"; import { buildFidelityReport, fidelityStatus } from "../fidelity/fidelity-report"; import type { FidelityBlockReport } from "../fidelity/fidelity-types"; - -const PIXELS_PER_INCH = 96; - -function pixelsToInches(px: number): number { - return px / PIXELS_PER_INCH; -} +import type PptxGenJS from "pptxgenjs"; +import { derivePptxSlideSize, documentUnitToPptxInches } from "../geometry"; +import { validateExportScene, type ExportSceneDiagnostic } from "../export-scene"; +import { resolveTheme, hexToPptx } from "../resolved-theme"; async function toUint8Array(value: string | Blob | ArrayBuffer | Uint8Array): Promise<Uint8Array<ArrayBuffer>> { if (value instanceof ArrayBuffer) return new Uint8Array(value); @@ -33,72 +34,134 @@ async function toUint8Array(value: string | Blob | ArrayBuffer | Uint8Array): Pr return new Uint8Array(await value.arrayBuffer()); } -interface PptxAddCallable { - addText?: (...args: unknown[]) => void; - addImage?: (...args: unknown[]) => void; - addShape?: (...args: unknown[]) => void; - addTable?: (...args: unknown[]) => void; - addChart?: (...args: unknown[]) => void; - addNotes?: (...args: unknown[]) => void; +/** + * Normalize intrinsic pixel dimensions to a sub-100-inch representation that + * preserves the aspect ratio. pptxgenjs reads the element w/h as the source + * image size for crop math (and treats any value >= 100 as EMU, not inches), so + * the scale here is arbitrary but must keep both axes below 100. Only the ratio + * matters: `cover`/`contain` srcRect percentages are derived from it. + */ +function naturalAspectInches(width: number, height: number): { w: number; h: number } { + const max = Math.max(width, height); + if (!isFinite(max) || max <= 0) return { w: 0, h: 0 }; + const scale = 4 / max; + return { w: width * scale, h: height * scale }; } -function writeElementToSlide(pptxSlide: PptxAddCallable, element: PptxSlideElement): void { - // PptxGenJS uses inches; our deck model uses pixels (96 DPI). +/** + * Place one element on a PPTX slide. Element geometry is in DOCUMENT pixels; + * the slide is sized with the derived PPTX geometry, so each axis maps by pure + * ratio (Phase 5). No fixed pixels-per-inch constant: the relationship between + * document space and PPTX inches is established once in the geometry layer. + */ +async function writeElementToSlide( + pptxSlide: PptxGenJS.Slide, + element: PptxSlideElement, + ctx: PptxExportContext, +): Promise<void> { + if (!element || element.w <= 0 || element.h <= 0) return; + const opts = { - x: pixelsToInches(element.x), - y: pixelsToInches(element.y), - w: pixelsToInches(element.w), - h: pixelsToInches(element.h), + x: documentUnitToPptxInches(element.x, ctx.slideWidth, ctx.pptxWidth), + y: documentUnitToPptxInches(element.y, ctx.slideHeight, ctx.pptxHeight), + w: documentUnitToPptxInches(element.w, ctx.slideWidth, ctx.pptxWidth), + h: documentUnitToPptxInches(element.h, ctx.slideHeight, ctx.pptxHeight), }; switch (element.type) { case "text": { - const data = element.data as { text: string; options?: Record<string, unknown> }; - pptxSlide.addText?.(data.text, { ...opts, ...data.options }); + pptxSlide.addText(element.data.text, { + ...opts, + ...element.data.options, + } as unknown as PptxGenJS.TextPropsOptions); break; } case "image": { - const data = element.data as { dataUri: string; options?: Record<string, unknown> }; - pptxSlide.addImage?.({ data: data.dataUri }, { ...opts, ...data.options }); + // pptxgenjs computes the cover/contain `srcRect` crop from the element's + // w/h aspect (its `imgSize`) against the sizing box. The element's final + // size still comes from `sizing.w/h` (the frame), so we override w/h with + // the source image's intrinsic aspect — normalized to a sub-100-inch + // scale — so the crop matches the web `object-fit` instead of stretching. + const natural = + element.data.naturalWidth && element.data.naturalHeight + ? naturalAspectInches(element.data.naturalWidth, element.data.naturalHeight) + : null; + pptxSlide.addImage({ + data: element.data.dataUri, + altText: element.data.alt, + ...opts, + w: natural?.w ?? opts.w, + h: natural?.h ?? opts.h, + ...element.data.options, + } as unknown as PptxGenJS.ImageProps); break; } case "shape": { - const data = element.data as { shape: string; options?: Record<string, unknown> }; - pptxSlide.addShape?.(data.shape, { ...opts, ...data.options }); + pptxSlide.addShape(element.data.shape as PptxGenJS.SHAPE_NAME, { + ...opts, + ...element.data.options, + } as unknown as PptxGenJS.ShapeProps); break; } case "table": { - const data = element.data as { rows: unknown[][]; options?: Record<string, unknown> }; - pptxSlide.addTable?.(data.rows, { ...opts, ...data.options }); + pptxSlide.addTable(element.data.rows as unknown as PptxGenJS.TableRow[], { + ...opts, + ...element.data.options, + } as unknown as PptxGenJS.TableProps); break; } case "chart": { - const data = element.data as { chartType: string; data: unknown; options?: Record<string, unknown> }; - pptxSlide.addChart?.(data.chartType, data.data, { ...opts, ...data.options }); + pptxSlide.addChart(element.data.chartType as PptxGenJS.CHART_NAME, element.data.data as never, { + ...opts, + ...element.data.options, + } as unknown as PptxGenJS.IChartOpts); break; } case "fallback": { - const data = element.data as { text: string; options?: Record<string, unknown> }; - pptxSlide.addText?.(data.text, { ...opts, fill: { color: "FFF3CD" }, color: "856404", fontSize: 12 }); + // Use resolved theme colors for fallback elements + const theme = resolveTheme(ctx.deck); + pptxSlide.addText(element.data.text, { + ...opts, + fill: { color: hexToPptx(theme.tokens.surface) }, + color: hexToPptx(theme.tokens.muted), + fontSize: 12, + ...element.data.options, + } as unknown as PptxGenJS.TextPropsOptions); break; } case "svg": { - const data = element.data as { svg: string; options?: Record<string, unknown> }; - pptxSlide.addImage?.( - { data: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(data.svg)}` }, - { ...opts, ...data.options }, - ); + // The SVG fallback (charts, diagrams, video posters) must become a PNG. + // In Node there is no browser `Image`/`canvas` for PptxGenJS's built-in + // SVG preview, so the SVG is rasterized here with resvg to a crisp 2x PNG. + // In the browser that preview works, so the SVG data-URI is passed through + // and PptxGenJS rasterizes it client-side (the native resvg binding cannot + // run in the browser and is kept out of the browser bundle via a lazy + // import). + if (typeof document === "undefined") { + const { renderSvgToPng } = await import("../fidelity/svg/svg-raster"); + const png = renderSvgToPng(element.data.svg, element.w * 2); + pptxSlide.addImage({ + data: `data:image/png;base64,${png.toString("base64")}`, + altText: element.data.alt, + ...opts, + } as unknown as PptxGenJS.ImageProps); + } else { + // PptxGenJS requires a base64 header; it then keeps the SVG part and + // rasterizes a PNG preview client-side via canvas. + const bytes = new TextEncoder().encode(element.data.svg); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + pptxSlide.addImage({ + data: `data:image/svg+xml;base64,${btoa(binary)}`, + altText: element.data.alt, + ...opts, + } as unknown as PptxGenJS.ImageProps); + } break; } } } -/** - * Derive the overall export status from the accumulated issues, per-block - * statuses, and the fidelity-level status. A report can only be `complete` - * when every block exported natively, no warning/error-severity issue was - * recorded, and the content-parity gate passed. - */ export function deriveExportStatus( issues: ExportIssue[], slideReports: ExportSlideReport[], @@ -126,59 +189,20 @@ export interface ExportBuildResult { fidelity: FidelityReport; } -/** - * Resolve semantic slot frames for a slide so slot-positioned blocks get real - * coordinates. Falls back to the block's own frame or defaults. - */ -function resolveBlockFrames( - slide: DeckSlide, - canvas: DeckProject["canvas"] -): Map<string, { x: number; y: number; w: number; h: number }> { - const frameByBlockId = new Map<string, { x: number; y: number; w: number; h: number }>(); - - // Use layoutBindings to map blocks to their slot frames. - const bindings = slide.layoutBindings ?? []; - const safe = canvas.safeMargin ?? 64; - const w = canvas.width ?? 1600; - const h = canvas.height ?? 900; - const innerW = w - 2 * safe; - const innerH = h - 2 * safe; - - // Simple deterministic slot layout: distribute bound blocks vertically. - const slotCount = Math.max(1, bindings.length); - const slotHeight = innerH / slotCount; - const slotWidth = innerW; - - bindings.forEach((binding, index) => { - const frame = { - x: safe, - y: safe + index * slotHeight, - w: slotWidth, - h: slotHeight, - }; - for (const blockId of binding.blockIds) { - frameByBlockId.set(blockId, frame); - } - }); - - return frameByBlockId; -} - -/** - * Convert every slide/block into PPTX elements while recording per-block - * status and typed issues. Pure relative to the artifact generation so it can - * be exercised without a PPTX library. - */ export async function buildExportReport( - deck: DeckProject, + input: PreparedExport | DeckProject, config: PptxExportConfig ): Promise<ExportBuildResult> { - const ctx = createExportContext(deck, config); + const prepared: PreparedExport = isPreparedExport(input) + ? input + : await prepareExport(input, config); + const deck = prepared.deck; + const ctx = createExportContext(deck, config, prepared); const issues: ExportIssue[] = []; const slideReports: ExportSlideReport[] = []; const slides: ExportBuildResult["slides"] = []; - for (const slide of deck.slides ?? []) { + for (const slide of deck.slides) { if (!config.includeHiddenSlides && slide.hidden) { issues.push({ code: "hidden-slide-skipped", @@ -194,10 +218,22 @@ export async function buildExportReport( const blockReports: ExportBlockReport[] = []; const elements: PptxSlideElement[] = []; - // Resolve semantic slot frames so slot-positioned blocks get real coordinates. - const frameByBlockId = resolveBlockFrames(slide, deck.canvas); + const scene = resolveSlideGeometry(slide, deck.canvas); + const frameByBlockId = scene.frameByBlockId; + + const slotGroups = new Map<string, ResolvedBlockGeometry[]>(); + for (const entry of scene.blocks) { + if (!entry.slotId) continue; + const group = slotGroups.get(entry.slotId) ?? []; + group.push(entry); + slotGroups.set(entry.slotId, group); + } + + const processedBlockIds = new Set<string>(); + + for (const block of slide.blocks) { + if (processedBlockIds.has(block.id)) continue; - for (const block of slide.blocks ?? []) { let result: PptxBlockExport; if (block.hidden) { @@ -216,10 +252,33 @@ export async function buildExportReport( } else { const exporter = getBlockExporter(block.type); try { - // Attach the resolved slot frame to the block so exporters use real coordinates. const resolvedFrame = frameByBlockId.get(block.id); - const blockWithFrame = resolvedFrame - ? { ...block, frame: { ...(block.frame ?? {}), ...resolvedFrame } } + let adjustedFrame = resolvedFrame + ? { ...resolvedFrame } + : undefined; + + if (resolvedFrame) { + const entry = scene.blocks.find((candidate) => candidate.blockId === block.id); + if (entry?.slotId) { + const group = slotGroups.get(entry.slotId) ?? [entry]; + const blockIndex = group.indexOf(entry); + const blocksInSlot = group.length; + if (blocksInSlot > 1) { + const gap = 12; + const availableH = resolvedFrame.h - gap * (blocksInSlot - 1); + const slotH = Math.max(40, Math.floor(availableH / blocksInSlot)); + adjustedFrame = { + x: resolvedFrame.x, + y: resolvedFrame.y + blockIndex * (slotH + gap), + w: resolvedFrame.w, + h: slotH, + }; + } + } + } + + const blockWithFrame = adjustedFrame + ? { ...block, frame: { ...block.frame, ...adjustedFrame }, resolvedFrame: adjustedFrame } : block; result = await exporter.export(blockWithFrame, ctx); } catch (err) { @@ -258,14 +317,85 @@ export async function buildExportReport( ); blockReports.push(planned); issues.push(...stampedIssues); - if (result.element) elements.push(result.element); + const emitted = result.elements?.length + ? result.elements + : result.element + ? [result.element] + : []; + for (const element of emitted) { + if (element.w > 0 && element.h > 0) { + elements.push(element); + } + } } slideReports.push({ slideId: slide.id, blocks: blockReports }); slides.push({ slide, elements }); } - const exportedSlides = (deck.slides ?? []).filter( + // ── Chart invariants ──────────────────────────────────────────────────────── + // "New chart" templates are NEVER counted: the source set is the visible, + // non-template chart blocks that carry real data. The exported set is every + // element (native chart OR SVG fidelity fallback) whose elementId maps back + // to one of those source blocks. Every exported chart MUST have a + // sourceBlockId; a chart with no source block is an orphan and is rejected. + const sourceChartBlockIds = new Set<string>(); + for (const slide of deck.slides) { + if (!config.includeHiddenSlides && slide.hidden) continue; + for (const block of slide.blocks) { + if (block.type !== "chart") continue; + const content = block.content as ChartContent | undefined; + if (content?.isTemplate) continue; + if (!Array.isArray(content?.values) || !content.values.length) continue; + sourceChartBlockIds.add(block.id); + } + } + + const allExportedElements = slides.flatMap(({ elements }) => elements); + const exportedChartCount = allExportedElements.filter( + (element) => !!element.elementId && sourceChartBlockIds.has(element.elementId), + ).length; + + if (sourceChartBlockIds.size !== exportedChartCount) { + issues.push({ + code: "chart-count-mismatch", + severity: "error", + message: `Chart count mismatch: ${sourceChartBlockIds.size} source charts but ${exportedChartCount} exported charts`, + automaticFixAvailable: false, + }); + } + + // Every native chart element must originate from a real source chart block. + const orphanCharts = allExportedElements.filter( + (element) => + element.type === "chart" && + !(element.elementId && sourceChartBlockIds.has(element.elementId)), + ); + if (orphanCharts.length > 0) { + issues.push({ + code: "chart-count-mismatch", + severity: "error", + message: `Exported ${orphanCharts.length} chart element(s) with no matching source chart block`, + automaticFixAvailable: false, + }); + } + + const sceneDiagnostics = validateExportScene( + { slides: slides.map(({ slide, elements }) => ({ slideId: slide.id, elements })) }, + ctx, + ); + for (const diagnostic of sceneDiagnostics) { + issues.push({ + code: diagnostic.code, + severity: diagnostic.severity === "error" ? "error" : "warning", + slideId: diagnostic.slideId, + blockId: diagnostic.elementId, + message: diagnostic.message, + automaticFixAvailable: false, + }); + } + + const exportedSlides = deck.slides.filter( (slide) => config.includeHiddenSlides || !slide.hidden, ); const fidelityBlocks = slideReports.flatMap((slide) => slide.blocks); @@ -295,27 +425,39 @@ export class PptxExporter { this.config = config; } - async export(deck: DeckProject): Promise<PptxExportResult> { - const { report, slides, fidelity } = await buildExportReport(deck, this.config); + async export(input: PreparedExport | DeckProject): Promise<PptxExportResult> { + // The canonical, single preparation phase. When a PreparedExport is passed + // (as the dialog always does), no resolution work happens here — the + // exporter consumes the already-resolved registry. A raw DeckProject is + // prepared on the fly for programmatic callers. + const prepared: PreparedExport = isPreparedExport(input) + ? input + : await prepareExport(input, this.config); + const deck = prepared.deck; + + const { report, slides, fidelity } = await buildExportReport(prepared, this.config); const PptxGenJS = (await import("pptxgenjs")).default; const pptx = new PptxGenJS(); - const canvas = deck.canvas ?? { width: 13.333, height: 7.5 }; - const slideWidthInches = pixelsToInches(canvas.width ?? 13.333); - const slideHeightInches = pixelsToInches(canvas.height ?? 7.5); - pptx.defineLayout({ name: "CUSTOM", width: slideWidthInches, height: slideHeightInches }); + // Phase 4: PPTX slide size is DERIVED from the document pixels so the + // exported aspect ratio always matches the web canvas (no hard-coded + // 13.333"x7.5" that would distort e.g. a 1920x800 "wide" canvas). + const canvas = deck.canvas ?? { width: 1600, height: 900 }; + const pptxSize = derivePptxSlideSize(canvas.width ?? 1600, canvas.height ?? 900); + pptx.defineLayout({ name: "CUSTOM", width: pptxSize.width, height: pptxSize.height }); pptx.layout = "CUSTOM"; for (const { slide, elements } of slides) { - const pptxSlide = pptx.addSlide() as PptxAddCallable; + const pptxSlide = pptx.addSlide(); if (slide.speakerNotes && this.config.includeSpeakerNotes) { - pptxSlide.addNotes?.(slide.speakerNotes); + pptxSlide.addNotes(slide.speakerNotes); } + const slideCtx = createExportContext(deck, this.config, prepared); for (const element of elements) { - writeElementToSlide(pptxSlide, element); + await writeElementToSlide(pptxSlide, element, slideCtx); } } @@ -326,14 +468,51 @@ export class PptxExporter { type: "application/vnd.openxmlformats-officedocument.presentationml.presentation", }); - const expectedTexts = (slides ?? []) - .flatMap(({ slide }) => (slide.blocks ?? []).filter((block) => !block.hidden).map((block) => rawText(block))) - .filter((text) => text.length > 0); - const expectedNotes = slides.filter( - ({ slide }) => this.config.includeSpeakerNotes && !!slide.speakerNotes, - ).length; - - const verification = await verifyPptxArchive({ report, blob, expectedTexts, expectedNotes }); + // Semantic native-text corpus: the EXACT text the exporter wrote into each + // native text/fallback/shape element. Verifying this (rather than a rawText + // reconstruction) is what makes text-survival meaningful — bullets keep + // their "•" prefixes, process steps their shape text, etc. + const nativeTextExpected = slides.flatMap(({ elements }) => + elements + .filter((element) => element.type === "text" || element.type === "fallback" || element.type === "shape") + .map((element) => { + if (element.type === "shape") { + const text = (element.data.options as { text?: unknown } | undefined)?.text; + return typeof text === "string" ? text : ""; + } + const data = element.data as { text?: string | PptxTextRun[] }; + return Array.isArray(data.text) + ? data.text.map((run) => run.text ?? "").join(" ") + : (data.text ?? ""); + }) + .filter((text) => text.length > 0), + ); + + // Semantic visual-fallback corpus: alt/description on SVG/raster elements. + // These survive as element attributes in the slide XML, not as <a:t> runs. + const visualFallbackTexts = slides.flatMap(({ elements }) => + elements + .filter((element) => element.type === "svg" || element.type === "image") + .flatMap((element) => { + const alt = (element.data as { alt?: string }).alt; + return alt && alt.length > 0 ? [alt] : []; + }), + ); + + // pptxgenjs always emits one notesSlide part per exported slide, even when + // the slide has no speaker notes. The speaker-notes structural check must + // therefore expect one notes part per slide, not only for slides that + // happen to carry notes (which would fail every export of a no-notes deck). + const expectedNotes = this.config.includeSpeakerNotes ? slides.length : 0; + + const verification = await verifyPptxArchive({ + report, + blob, + nativeTextExpected, + visualFallbackTexts, + expectedNotes, + includeSpeakerNotes: this.config.includeSpeakerNotes, + }); const archiveVerified = verification.passed; const allIssues: ExportIssue[] = [...report.issues]; @@ -354,4 +533,4 @@ export class PptxExporter { return { report, blob, archiveVerified, fidelity }; } -} \ No newline at end of file +} diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-fallback-renderer.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-fallback-renderer.ts index 8e33fec..fe62657 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-fallback-renderer.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-fallback-renderer.ts @@ -1,4 +1,4 @@ -// starter-components/export/pptx/pptx-fallback-renderer.ts +// export/pptx/pptx-fallback-renderer.ts import type { PptxExportContext, PptxSlideElement } from "../export-types"; @@ -8,13 +8,14 @@ export async function renderFallback( reason: string ): Promise<PptxSlideElement> { const blockType = (block.type as string) ?? "unknown"; + const frame = (block.frame as { x?: number; y?: number; w?: number; h?: number } | undefined) ?? {}; return { type: "fallback", - x: (block.x as number) ?? 0, - y: (block.y as number) ?? 0, - w: (block.w as number) ?? ctx.slideWidth * 0.5, - h: (block.h as number) ?? ctx.slideHeight * 0.3, + x: (block.x as number) ?? frame.x ?? 0, + y: (block.y as number) ?? frame.y ?? 0, + w: (block.w as number) ?? frame.w ?? ctx.slideWidth * 0.5, + h: (block.h as number) ?? frame.h ?? ctx.slideHeight * 0.3, data: { text: `[${blockType}: ${reason}]`, options: { diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-fonts.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-fonts.ts index 50b395b..2a3d411 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-fonts.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-fonts.ts @@ -1,4 +1,4 @@ -// starter-components/export/pptx/pptx-fonts.ts +// export/pptx/pptx-fonts.ts import type { FontWarning } from "../export-types"; @@ -16,6 +16,32 @@ const PPTX_SAFE_FONTS = new Set([ "Times New Roman", "Trebuchet MS", "Verdana", ]); +/** + * Registry of web font families (as used by the deck themes) to their closest + * PPT-safe substitutes. Kept in sync with deck/themes.ts so exported slides + * stay visually coherent even when the web font is unavailable in PowerPoint. + */ +const WEB_TO_SUBSTITUTES: Record<string, string> = { + "Inter": "Arial", + "Manrope": "Arial", + "IBM Plex Sans": "Arial", + "Sora": "Arial", + "Libre Baskerville": "Georgia", + "JetBrains Mono": "Consolas", +}; + +/** + * Resolve a web/theme font to a PPT-safe family. Returns the input unchanged + * when it is already PPT-safe. + */ +export function resolvePptxFont(fontFamily: string): string { + if (!fontFamily) return "Arial"; + const cleanName = fontFamily.replace(/['"]/g, "").trim().split(",")[0].trim(); + if (PPTX_SAFE_FONTS.has(cleanName)) return cleanName; + if (WEB_TO_SUBSTITUTES[cleanName]) return WEB_TO_SUBSTITUTES[cleanName]; + return "Arial"; +} + export function checkFontCompatibility( fontFamily: string, slideId?: string, @@ -35,7 +61,7 @@ export function checkFontCompatibility( fontFamily: cleanName, slideId, blockId, - substituteFont: "Arial", + substituteFont: resolvePptxFont(cleanName), }; } @@ -54,4 +80,4 @@ export function collectFontWarnings(deck: { slides?: Array<{ id?: string; blocks } return warnings; -} \ No newline at end of file +} diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-placeholder.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-placeholder.ts new file mode 100644 index 0000000..99ec7dc --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-placeholder.ts @@ -0,0 +1,17 @@ +// export/pptx/pptx-placeholder.ts +// +// Bundled, deterministic placeholder IMAGE used when a remote/manifest asset +// cannot be resolved at export time (e.g. offline or a dead URL). +// +// Regression (P2-004): the image exporter used to replace an unresolvable +// image with a labeled TEXT box ("Image unavailable: …"). That is forbidden by +// the export contract: it leaks placeholder text into the deck and violates +// "never export [image unavailable: …]" and "never silently drop the image". +// Instead, a real raster element is embedded at the resolved frame — the visual +// slot is filled, the image "appears in PPTX", and the fallback stays a +// fidelity-first substitution rather than a missing block. +// +// 480x360 theme-integrated "image" glyph (cream gradient, frame, sun + peak). + +export const PLACEHOLDER_IMAGE_DATA_URI = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAeAAAAFoCAIAAAAAVb93AAAIl0lEQVR42u3YSVIbQRRFUS2XrbAXBuyARTAxPYhGom+M7akn9oAsBUK2VPUy60ScBYjU+5cITX693QIQaOIJAAQaAIEGEGgABBpAoAEQaAAEGkCgARBogLYDPQcg0OTn6xyAQAININAACDSAQAMg0AACDYBAAyDQAAINgEADCDQAAg2AQAMINAACDSDQAAg0gEADINAALA/0DIBAAg2QGugfLzMAAgk0gEADINAAAg2AQAMINAACDYBAAwg0AAININAACDQAAg0g0AAINIBAAyDQAAINQFSgbwAIJNAAAg2AQAO0EOj35xsAAgk0gEADINAAAg2AQAMINAACDYBAAwg0AAININAACDQAAr02u9tbQJc4CLQig14LNNIMMi3Q0gwyzeT9+ZqlnBNsoNHasoRAr6fOXgkcjkAHjczjgDsS6LhVeRlwUAKduCfPAm6qp0B/f7pmoYVL8izgsnoj0CvMyJuA4xJoAwInhkBbDzgxga56PR4EXJlAmw64MgTadMCVCbRfxwCHJtD+sYNbE2ijAdyaQBsNuDWBxmjArQm00QBuTaCNBtyaQBsN4NYE2mjArQm00RgNuLUBA31FoTMabwJubQACbTTg1gTaaAC3JtBGA25NoI0GcGsbC/Tb4xWFYjQeBNzaIATaaMCtCbTRAG5NoI0G3JpAGw3g1gTaaMCtCTRGA25NoI0G3JpbE2ijAbcm0EYDuDWBNhpwawJtNIBbE2ijAbeGQBsNuDWBNhrArQm00YBbaynQlxQ6o/Em4NYGINBGA25NoI0GcGsCbTTg1gTaaAC3JtBGA25NoDEacGsRgX59uKRQjGZUf/v+3o4B4NZCCPRIR7O/t7Mqw8CtCbTRZHVZqXFrAm00FaRZpnFrAm00uV1WatyaQBtNHXXWaNyaQBtNaJplGoEWaKNJr7NGI9ACbTS5ddZoBFqgjSY0zTKNQAu00aTXWaMRaIE2mtw6azQCLdBGI9C4NYE2GnXWaAS690BPKXRGk/tRw+v8t9FGRfW3NgiBrng0VdRZoxFogRZogcatCbTRqLNGI9ACbTTN1FmjEWiBFmiBxq0JtNEItEAj0AJtNG3UWaMR6JUD/XI/pVCMJu3jVR1o66KiWxucQFc2mqrrrNEItEALtEDj1gTaaARaoBFogTaaNuqs0Qi0QAu0QOPWBNpoBFqgEWiBNhqBxq0JtNEItEAj0AJtNAKNWxNogRZogcatCbTRCLRAI9ACPabRNFNnjUagvx7oCwqd0aR8sIbqbGZE31oIgRZogcatCbTRCLRAI9ACLdACjVsTaKMRaIFGoAXaaASapg9NoAVaoAWaxDQLtEALtEATmmaBFugWfhdTZ5pMs0ALtEALNKFpFujFgX6+u6BQjCbqszUQaAMb5x19hUcrCHRlga690dYlzQIt0AIt0NSUZoEW6EYCXW+jTUuaBVqgBVqgqSzNAi3QAi3QhKZZoAW6nUDX2Gi70mWBFuixBLquRhuVNAu0QAu0QFNlmgVaoFsLdC2NtihpFmiBHmOg8xttTtIs0P8T6HMKndGkf+DgOptT9fvvN9De/wOBFmiBZvg0C7RANxvozEYbkjQLtEALdGKjrUiaBVqgBTou0/YjzQIt0AKd2GjjkWaBFmiBTmy05eiyQAu0QMdl2makWaAFWqATG20w0izQAi3QWaW2E2kWaIEW6LhMW4g0C3RPgX66PadQjKbJv/EfumwYde22Rr7EgkCPNNCfhNsApFmgBVqgQZrdmkALNNIs0AIt0CDNAi3QAo00C7RAC7Q3QZcFWqAFGsaZZrcm0AKNNAu0QAs0SLNAryPQZxQ6o/EmDDC8UQbaDD4QaIFGlwVaoAUapFmgBVqgkWaBFmiBBmkWaIEWaKRZoAUao0GaBVqgBRpdxq0JtEAjzQIt0AKN8SDQAi3QSLNAC7RAgzQLtEALNLos0PwJ9OP8jEIxGg/C5wthXUyrINACjTQLtEALNNKMQAu0QCPNAi3QAo00I9ACLdDoskALNEbjq0egBVqgkWbc2meBPqXQGY03GdHXzaCBNsgPBFqgfdEItEALNLqMQAu0QCPNAi3QAo00I9ACLdBIs0ALNEYjzQi0QAs00oxbE2iB1mUEWqAFGmlGoAVaoJFmgRZogUaaEWiBFmikWaARaIHWZQQ6O9APs1MKxWg8SNo3QqtMvSDQAi3NCLRACzTSjEALtEBLMwIt0AKNNCPQAi3QuoxACzRGI80IdEigTyh0RuNNNv7I4Na6BFqgpRmBFmiB9rAahEALtEDrMgIt0AINuDWBNhpwawKN0YBbE2ijAbfm1gTaaMCtCbTRAG5NoI0G3JpAGw3g1gTaaMCtIdBGA25NoI0GcGsCbTTg1toJ9P3NCYViNB4E3NogBNpowK0JtNEAbk2gjQbcmkAbDeDWNhjoYwqd0XgTcGsDEGijAbcm0EYDuDWBNhpwawJtNIBbE2ijAbcm0BgNuDWBNhpwa25NoI0G3JpAGw3g1gS6p9HYDTg0gfaPHVwZAm064MoE2nQAVybQfh0DJybQ1gM4MYE2IHBc4w303fUxC3U3tLu95VnAZfVGoC0J3JRAtzImewIHJdDRk7IqcEcCHb0tIwOHs9FAH7HUV6YGrERYlhJojQZ1FmiZBqRZoGUapFmg0WtQZIEGEGgABBoAgQYQaAAEGkCgARBoAAQaQKABEGgAgQZgw4G+vToCIJBAAwg0AAIN0EagDwEIJNAAAg2AQAMINAACDSDQAAg0AAININAACDSAQAMg0AAINIBAAyDQAAINgEADCDQAQYGeXx4CEEigAXIDfQBAIIEGEGgABBpAoAEQaACBBkCgARBoAIEGQKABBBoAgQZAoAEEGgCBBhBoAAQaQKABEGgAlgV6Nj0AINBkNv0GQCCBBhBoAAQaQKABEGgAgQZAoAEQaACBBkCgAVr2G4EF3RgL5/rzAAAAAElFTkSuQmCC"; diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-theme.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-theme.ts index 1f09558..c853e8e 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-theme.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-theme.ts @@ -1,5 +1,4 @@ -import type { DeckProject } from "../../deck-types"; -import type PptxGenJS from "pptxgenjs"; +import type { DeckProject } from "../../deck/types"; type DeckTheme = DeckProject["theme"]; @@ -43,14 +42,3 @@ export function mapThemeFonts(theme: DeckTheme): { heading: string; body: string body: typography.bodyFont ?? "Arial", }; } - -export function applyThemeToPptx(pptx: PptxGenJS, theme: DeckTheme): void { - const fonts = mapThemeFonts(theme); - - // PptxGenJS ThemeProps only supports font faces; theme colors are applied - // per-element via mapThemeColors() in the block exporters. - pptx.theme = { - headFontFace: fonts.heading, - bodyFontFace: fonts.body, - }; -} diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-verifier.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-verifier.ts index 0177fb2..497bc97 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-verifier.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/pptx/pptx-verifier.ts @@ -4,10 +4,23 @@ import type { ExportReport, PptxVerificationCheck, PptxVerificationReport } from export interface VerificationInput { report: ExportReport; blob: Blob; - /** Text fragments that must survive somewhere in the archive's slide <a:t> runs. */ + /** Legacy alias for native text that must survive in slide <a:t> runs. */ expectedTexts?: string[]; + /** + * Semantic native-text corpus: text fragments from blocks exported as + * native text elements. EVERY fragment must survive in <a:t> runs. + */ + nativeTextExpected?: string[]; + /** + * Semantic visual-fallback corpus: alt/description fragments from blocks + * exported as SVG/raster elements. These must survive in the slide XML + * (as element attributes such as `descr`), not necessarily in <a:t> runs. + */ + visualFallbackTexts?: string[]; /** Number of speaker-notes parts expected (slides with notes in this export). */ expectedNotes?: number; + /** When false the speaker-notes check is NOT APPLICABLE and always passes. */ + includeSpeakerNotes?: boolean; } function decode(entryText: string): string { @@ -25,6 +38,15 @@ function normalizeText(text: string): string { return decode(text).replace(/<[^>]*>/g, " ").replace(/ /g, " ").replace(/\s+/g, " ").trim().toLowerCase(); } +/** + * Collapse whitespace over the DECODED XML including attribute values, so a + * phrase stored in an attribute (e.g. `descr="..."` alt text on an image or + * SVG element) can be located without needing to parse the XML. + */ +function normalizeXmlCollapsed(text: string): string { + return decode(text).replace(/ /g, " ").replace(/\s+/g, " ").trim().toLowerCase(); +} + function slidePartName(name: string): boolean { return /^ppt\/slides\/slide\d+\.xml$/.test(name); } @@ -41,9 +63,18 @@ export async function verifyPptxArchive(input: VerificationInput): Promise<{ passed: boolean; report: PptxVerificationReport; }> { - const { report, blob, expectedTexts = [], expectedNotes } = input; + const { + report, + blob, + expectedTexts = [], + nativeTextExpected = [], + visualFallbackTexts = [], + expectedNotes, + includeSpeakerNotes = true, + } = input; const checks: PptxVerificationCheck[] = []; const expectedSlides = report.slides.length; + const nativeCorpus = [...expectedTexts, ...nativeTextExpected].filter((text) => text && text.length > 0); try { const zipData = @@ -58,34 +89,63 @@ export async function verifyPptxArchive(input: VerificationInput): Promise<{ }); const notesCount = Object.keys(zip.files).filter(notesPartName).length; - const notesExpected = expectedNotes ?? expectedSlides; - checks.push({ - name: "speaker-notes", - passed: notesCount === notesExpected, - detail: `expected notes for ${notesExpected} slides, found ${notesCount}`, - }); + if (includeSpeakerNotes === false) { + // Regression (P2-002): speaker-notes is NOT APPLICABLE when the user + // disabled notes; it must not be compared against an expectation. + checks.push({ + name: "speaker-notes", + passed: true, + detail: "not-applicable: speaker notes disabled", + }); + } else { + const notesExpected = expectedNotes ?? expectedSlides; + checks.push({ + name: "speaker-notes", + passed: notesCount === notesExpected, + detail: `expected notes for ${notesExpected} slides, found ${notesCount}`, + }); + } const slideTexts: string[] = []; + const slideXmlCollapsed: string[] = []; for (const name of archiveSlides) { const entry = zip.file(name); if (!entry) continue; const raw = await entry.async("string"); const texts = raw.match(/<a:t>([^<]*)<\/a:t>/g) ?? []; slideTexts.push(...texts.map((t) => t.replace(/<\/?a:t>/g, ""))); + slideXmlCollapsed.push(normalizeXmlCollapsed(raw)); } const combined = normalizeText(slideTexts.join(" ")); - const missing: string[] = []; - for (const expected of expectedTexts) { + const missingNative: string[] = []; + for (const expected of nativeCorpus) { const normalized = normalizeText(expected); if (normalized && !combined.includes(normalized)) { - missing.push(`missing text: "${expected}"`); + missingNative.push(`missing text: "${expected}"`); } } checks.push({ name: "text-survival", - passed: missing.length === 0, - detail: missing.length === 0 ? "all expected text found" : missing.join("; "), + passed: missingNative.length === 0, + detail: missingNative.length === 0 ? "all expected text found" : missingNative.join("; "), + }); + + const collapsed = slideXmlCollapsed.join(" "); + const missingFallback: string[] = []; + for (const expected of visualFallbackTexts) { + const normalized = normalizeXmlCollapsed(expected); + if (normalized && !collapsed.includes(normalized)) { + missingFallback.push(`missing alt/description: "${expected}"`); + } + } + checks.push({ + name: "visual-fallback-alt", + passed: missingFallback.length === 0, + detail: + missingFallback.length === 0 + ? "all fallback alt/description text found" + : missingFallback.join("; "), }); const missingRels = archiveSlides.filter((name) => !zip.file(relsPartName(name))); diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/prepare-export.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/prepare-export.ts new file mode 100644 index 0000000..b621987 --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/prepare-export.ts @@ -0,0 +1,181 @@ +// export/prepare-export.ts +// +// THE single asynchronous export-preparation phase. +// +// `prepareExport` is the ONLY place that performs network/asset work. It +// resolves every required visible image source to embeddable data URIs exactly +// once, builds the canonical asset registry (keyed by canonical asset id: +// manifest id or `inline:<blockId>`), and freezes an immutable snapshot of the +// deck. Preflight, fidelity accounting, and the PPTX exporter all consume the +// resulting `PreparedExport` and must never re-resolve or reinterpret assets. +// +// Contract: +// - Preflight operates on the PREPARED snapshots + registry, so "Ready to +// export" can never be printed while an unresolved required image exists. +// - The exporter consumes the registry, so it can never fail mid-export on a +// URL that preflight said was fine. +// - Each required source is fetched at most once per preparation. +// +// Type-safety note: `deck.assets` is technically optional on DeckProject, but +// every deck produced by this app carries a manifest array; we coerce to a +// stable array so registry lookups never see "undefined assets". + +import type { DeckProject } from "../deck/types"; +import type { PptxExportConfig } from "./export-types"; +import { canonicalAssetRef } from "../deck/assets"; +import { resolveSlideSnapshot, type ImmutableSlideSnapshot } from "./snapshot"; +import { embedAssetDetailed, type AssetEmbedResult } from "./pptx/pptx-assets"; + +export type PreparedAssetStatus = "ready" | "failed"; + +/** One entry in the canonical, pre-resolved asset registry. */ +export interface PreparedAsset { + /** Canonical registry key (manifest id or `inline:<blockId>`). */ + assetId: string; + /** The image block that first required this asset. */ + blockId?: string; + /** The concrete source URL (or data URI) the asset was resolved from. */ + originalSrc: string; + /** The embeddable data URI; empty when resolution failed. */ + resolvedDataUri: string; + mimeType: string; + width?: number; + height?: number; + status: PreparedAssetStatus; + /** Why resolution failed (network error, HTTP status, CORS, orphan, …). */ + error?: string; +} + +/** The frozen result of the single preparation phase. */ +export interface PreparedExport { + deck: DeckProject; + config: PptxExportConfig; + /** Canonical asset registry consumed by preflight + exporters. */ + assets: ReadonlyMap<string, PreparedAsset>; + /** Immutable snapshots for every slide selected by the config. */ + slides: ImmutableSlideSnapshot[]; +} + +/** Type guard discriminating a `PreparedExport` from a raw `DeckProject`. */ +export function isPreparedExport(value: unknown): value is PreparedExport { + return ( + !!value && + typeof value === "object" && + "config" in value && + "assets" in value && + !Array.isArray((value as { assets?: unknown }).assets) + ); +} + +interface RequiredAsset { + assetId: string; + blockId: string; + src?: string; + orphan?: boolean; +} + +/** + * Collect every required, visible image source. Manifest-backed blocks use + * their asset id as the canonical key; legacy inline `content.src`/`block.src` + * sources get a deterministic synthetic key. Placeholder blocks (no source) + * are intentionally not collected — they are rendered as a designed + * placeholder and never count against fidelity. + */ +function collectRequiredAssets(deck: DeckProject, includeHiddenSlides: boolean): RequiredAsset[] { + const required = new Map<string, RequiredAsset>(); + for (const slide of deck.slides) { + if (!includeHiddenSlides && slide.hidden) continue; + for (const block of slide.blocks) { + if (block.hidden || block.type !== "image") continue; + const ref = canonicalAssetRef(deck, block); + if (!ref) continue; + if (!required.has(ref.assetId)) { + required.set(ref.assetId, { + assetId: ref.assetId, + blockId: block.id, + src: ref.src, + orphan: ref.orphan, + }); + } + } + } + return [...required.values()]; +} + +async function buildAssetRegistry( + deck: DeckProject, + config: PptxExportConfig, +): Promise<PreparedAsset[]> { + const cache = new Map<string, AssetEmbedResult>(); + const entries: PreparedAsset[] = []; + + for (const req of collectRequiredAssets(deck, config.includeHiddenSlides)) { + const manifestAsset = !req.assetId.startsWith("inline:") + ? (deck.assets ?? []).find((asset) => asset.id === req.assetId) + : undefined; + + if (req.orphan) { + entries.push({ + assetId: req.assetId, + blockId: req.blockId, + originalSrc: "", + resolvedDataUri: "", + mimeType: manifestAsset?.mimeType ?? "image/png", + status: "failed", + error: `Image block "${req.blockId}" references asset "${req.assetId}" which has no manifest entry`, + }); + continue; + } + + const src = req.src ?? ""; + if (!src) { + entries.push({ + assetId: req.assetId, + blockId: req.blockId, + originalSrc: "", + resolvedDataUri: "", + mimeType: manifestAsset?.mimeType ?? "image/png", + status: "failed", + error: `Image block "${req.blockId}" has no resolvable source`, + }); + continue; + } + + const { result, error } = await embedAssetDetailed(src, cache); + entries.push({ + assetId: req.assetId, + blockId: req.blockId, + originalSrc: src, + resolvedDataUri: result.dataUri, + mimeType: result.mimeType || manifestAsset?.mimeType || "image/png", + width: manifestAsset?.width, + height: manifestAsset?.height, + status: result.dataUri ? "ready" : "failed", + error: result.dataUri + ? undefined + : `Image "${src}" (block "${req.blockId}") could not be fetched (${error ?? "network error, 404, CORS, or timeout"})`, + }); + } + + return entries; +} + +/** + * Prepare a deck for export. This is the ONE place assets are resolved; every + * downstream consumer (preflight, fidelity, PPTX exporter) must be handed the + * returned `PreparedExport` and must not perform its own resolution. + */ +export async function prepareExport( + deck: DeckProject, + config: PptxExportConfig, +): Promise<PreparedExport> { + const assetEntries = await buildAssetRegistry(deck, config); + const assets = new Map<string, PreparedAsset>(); + for (const entry of assetEntries) assets.set(entry.assetId, entry); + + const slides = deck.slides + .filter((slide) => config.includeHiddenSlides || !slide.hidden) + .map((slide) => resolveSlideSnapshot(slide, deck, assets)); + + return { deck, config, assets, slides }; +} \ No newline at end of file diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/resolved-theme.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/resolved-theme.ts new file mode 100644 index 0000000..6ff78c4 --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/resolved-theme.ts @@ -0,0 +1,253 @@ +// export/resolved-theme.ts +// +// THE single source of truth for theme resolution. +// This module provides a resolver that creates a fully-resolved theme +// from the DeckProject's theme configuration. +// +// The resolved theme is used by both the Web Renderer and PPTX Exporter +// to ensure color/typography parity. + +import type { DeckProject, ThemeDef, ThemeTokens } from "../deck/types"; +import { getTheme } from "../deck/themes"; + +// ─── Canonical Color Resolution ────────────────────────────────────────────── + +/** + * Normalize a CSS color to a canonical hex format. + * This ensures consistent color representation across all renderers. + */ +export function normalizeColor(color: string): string { + if (!color) return "#000000"; + + // Already hex + if (color.startsWith("#")) { + const hex = color.replace("#", ""); + if (hex.length === 3) { + return `#${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}`; + } + return `#${hex.slice(0, 6)}`; + } + + // RGB/RGBA + if (color.startsWith("rgb")) { + const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + if (match) { + const r = parseInt(match[1], 10).toString(16).padStart(2, "0"); + const g = parseInt(match[2], 10).toString(16).padStart(2, "0"); + const b = parseInt(match[3], 10).toString(16).padStart(2, "0"); + return `#${r}${g}${b}`; + } + } + + // Named colors - return as-is (CSS will handle) + return color; +} + +/** + * Parse a hex color to RGB components. + */ +export function hexToRgb(hex: string): { r: number; g: number; b: number } | null { + const normalized = normalizeColor(hex); + const match = normalized.match(/^#([0-9a-f]{6})$/i); + if (!match) return null; + + const hexStr = match[1]; + return { + r: parseInt(hexStr.slice(0, 2), 16), + g: parseInt(hexStr.slice(2, 4), 16), + b: parseInt(hexStr.slice(4, 6), 16), + }; +} + +/** + * Convert a hex color to PPTX format (without # prefix). + */ +export function hexToPptx(hex: string): string { + return normalizeColor(hex).replace("#", ""); +} + +// ─── Canonical Font Resolution ─────────────────────────────────────────────── + +const PPTX_SAFE_FONTS = new Set([ + "Arial", + "Calibri", + "Cambria", + "Candara", + "Consolas", + "Constantia", + "Corbel", + "Courier New", + "Georgia", + "Impact", + "Lucida Console", + "Palatino Linotype", + "Segoe UI", + "Tahoma", + "Times New Roman", + "Trebuchet MS", + "Verdana", +]); + +const WEB_TO_SUBSTITUTES: Record<string, string> = { + Inter: "Arial", + Manrope: "Arial", + "IBM Plex Sans": "Arial", + Sora: "Arial", + "Libre Baskerville": "Georgia", + "JetBrains Mono": "Consolas", +}; + +/** + * Resolve a web font to a PPTX-safe font family. + */ +export function resolvePptxFont(fontFamily: string): string { + if (!fontFamily) return "Arial"; + const cleanName = fontFamily.replace(/['"]/g, "").trim().split(",")[0].trim(); + if (PPTX_SAFE_FONTS.has(cleanName)) return cleanName; + if (WEB_TO_SUBSTITUTES[cleanName]) return WEB_TO_SUBSTITUTES[cleanName]; + return "Arial"; +} + +/** + * Check if a font is PPTX-safe. + */ +export function isPptxSafeFont(fontFamily: string): boolean { + const cleanName = fontFamily.replace(/['"]/g, "").trim().split(",")[0].trim(); + return PPTX_SAFE_FONTS.has(cleanName) || !!WEB_TO_SUBSTITUTES[cleanName]; +} + +// ─── Canonical Theme Resolution ────────────────────────────────────────────── + +export interface ResolvedTheme { + id: string; + tokens: ThemeTokens; + typography: { + headingFont: string; + bodyFont: string; + codeFont: string; + }; + chartPalette: string[]; + gradients: Record<string, string>; +} + +/** + * Resolve a DeckProject's theme to a fully-resolved theme. + * This is the single source of truth for all renderers. + */ +export function resolveTheme(deck: DeckProject): ResolvedTheme { + const themeDef = getTheme(deck.theme?.id ?? "editorial-cream"); + const overrides = deck.theme?.overrides ?? {}; + + // Apply overrides to tokens + const tokens: ThemeTokens = { + background: normalizeColor( + (overrides as Record<string, string>).background ?? themeDef.tokens.background + ), + foreground: normalizeColor( + (overrides as Record<string, string>).foreground ?? themeDef.tokens.foreground + ), + primary: normalizeColor( + (overrides as Record<string, string>).primary ?? themeDef.tokens.primary + ), + secondary: normalizeColor( + (overrides as Record<string, string>).secondary ?? themeDef.tokens.secondary + ), + surface: normalizeColor( + (overrides as Record<string, string>).surface ?? themeDef.tokens.surface + ), + muted: normalizeColor( + (overrides as Record<string, string>).muted ?? themeDef.tokens.muted + ), + surfaceElevated: normalizeColor( + (overrides as Record<string, string>).surfaceElevated ?? themeDef.tokens.surfaceElevated + ), + border: normalizeColor( + (overrides as Record<string, string>).border ?? themeDef.tokens.border + ), + focus: normalizeColor( + (overrides as Record<string, string>).focus ?? themeDef.tokens.focus + ), + }; + + // Apply overrides to typography + const typographyOverrides = (overrides.typography ?? {}) as Record<string, string>; + const typography = { + headingFont: typographyOverrides.headingFont ?? themeDef.typography.headingFont, + bodyFont: typographyOverrides.bodyFont ?? themeDef.typography.bodyFont, + codeFont: typographyOverrides.codeFont ?? themeDef.typography.codeFont, + }; + + // Apply overrides to chart palette + const chartPalette = Array.isArray(overrides.chartPalette) + ? (overrides.chartPalette as string[]).map(normalizeColor) + : themeDef.chartPalette.map(normalizeColor); + + // Apply overrides to gradients + const gradients = { + ...(themeDef.gradients ?? {}), + ...((overrides.gradients as Record<string, string>) ?? {}), + }; + + return { + id: themeDef.id, + tokens, + typography, + chartPalette, + gradients, + }; +} + +/** + * Get chart colors for a specific chart. + * Returns the resolved colors based on the theme's chart palette. + */ +export function resolveChartColors( + theme: ResolvedTheme, + seriesCount: number, + highlightIndex?: number +): { + seriesColors: string[]; + highlightColor: string; + axisColor: string; + gridColor: string; + labelColor: string; +} { + const palette = theme.chartPalette; + + // Generate series colors from palette + const seriesColors: string[] = []; + for (let i = 0; i < seriesCount; i++) { + seriesColors.push(palette[i % palette.length]); + } + + // Highlight color is the secondary color + const highlightColor = theme.tokens.secondary; + + return { + seriesColors, + highlightColor, + axisColor: theme.tokens.border, + gridColor: theme.tokens.border, + labelColor: theme.tokens.muted, + }; +} + +/** + * Get text color for a block based on its role. + */ +export function resolveTextColor( + theme: ResolvedTheme, + role?: "primary" | "secondary" | "muted" | "foreground" +): string { + switch (role) { + case "primary": + return theme.tokens.primary; + case "secondary": + return theme.tokens.secondary; + case "muted": + return theme.tokens.muted; + case "foreground": + default: + return theme.tokens.foreground; + } +} diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/self-contained.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/self-contained.ts new file mode 100644 index 0000000..29b9bc0 --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/self-contained.ts @@ -0,0 +1,104 @@ +// export/self-contained.ts +// +// "Make deck self-contained": rewrite every required visible image source into +// an embeddable data URI so the deck exports with zero network. Pure logic with +// an injectable embedder (defaults to the real fetch-based one) so unit tests +// run deterministically offline. +// +// Contract: +// - Manifest-backed assets are rewritten in place (id, width, height kept). +// - Inline-only image blocks (canonical `inline:<blockId>` refs) are +// normalized into a NEW manifest asset so every image lives in deck.assets +// afterwards, consistent with the `updateImageSource` command. +// - data: URIs pass through untouched. +// - A failed fetch is recorded (blockId + error) and the original source is +// kept, so preflight still blocks with the block-specific message. Never +// throws. + +import type { DeckProject, DeckSlide, Block } from "../deck/types"; +import { canonicalAssetRef, imageContentOf } from "../deck/assets"; +import { newId } from "../deck/seed"; +import { embedAssetDetailed, type AssetEmbedResult, type EmbedOutcome } from "./pptx/pptx-assets"; + +export type EmbedFn = ( + assetUrl: string, + cache: Map<string, AssetEmbedResult>, +) => Promise<EmbedOutcome>; + +export interface SelfContainedFailure { + blockId: string; + assetId?: string; + error: string; +} + +export interface SelfContainedResult { + deck: DeckProject; + embedded: number; + failures: SelfContainedFailure[]; +} + +export async function makeDeckSelfContained( + deck: DeckProject, + embed: EmbedFn = embedAssetDetailed, +): Promise<SelfContainedResult> { + const cache = new Map<string, AssetEmbedResult>(); + const failures: SelfContainedFailure[] = []; + let embedded = 0; + + // Pass 1: rewrite remote manifest entries in place (id/dimensions preserved). + const assets = (deck.assets ?? []).map((asset) => ({ ...asset })); + for (const asset of assets) { + if (!asset.src || asset.src.startsWith("data:")) continue; + const { result, error } = await embed(asset.src, cache); + if (result.dataUri) { + embedded += 1; + asset.src = result.dataUri; + if (result.mimeType) asset.mimeType = result.mimeType; + } else { + failures.push({ + blockId: firstImageBlockIdFor(deck, asset.id), + assetId: asset.id, + error: error ?? "fetch failed", + }); + } + } + + // Pass 2: normalize inline-only remote image blocks into the manifest. + const slides: DeckSlide[] = []; + for (const slide of deck.slides) { + let blocks: Block[] = slide.blocks; + for (const block of blocks) { + if (block.hidden || block.type !== "image") continue; + const ref = canonicalAssetRef(deck, block); + if (!ref || !ref.assetId.startsWith("inline:")) continue; + const src = ref.src; + if (!src || src.startsWith("data:")) continue; + const { result, error } = await embed(src, cache); + if (!result.dataUri) { + failures.push({ blockId: block.id, error: error ?? "fetch failed" }); + continue; + } + const assetId = newId("asset"); + assets.push({ id: assetId, kind: "image", src: result.dataUri, mimeType: result.mimeType }); + embedded += 1; + const content = imageContentOf(block); + blocks = blocks.map((b) => + b.id === block.id ? { ...b, content: { ...content, src: undefined, assetId } } : b, + ); + } + slides.push({ ...slide, blocks }); + } + + return { deck: { ...deck, assets, slides }, embedded, failures }; +} + +/** First visible image block bound to the given manifest asset, for failure reporting. */ +function firstImageBlockIdFor(deck: DeckProject, assetId: string): string { + for (const slide of deck.slides) { + for (const block of slide.blocks) { + if (block.hidden || block.type !== "image") continue; + if (canonicalAssetRef(deck, block)?.assetId === assetId) return block.id; + } + } + return ""; +} diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/export/snapshot.ts b/examples/02-example/.agents/skills/deckforge/starter-components/export/snapshot.ts new file mode 100644 index 0000000..ef063d1 --- /dev/null +++ b/examples/02-example/.agents/skills/deckforge/starter-components/export/snapshot.ts @@ -0,0 +1,595 @@ +// export/snapshot.ts +// +// THE single source of truth for the immutable export snapshot. +// This module defines the canonical snapshot types and the resolver that +// creates an immutable representation of the slide at export time. +// +// Architecture: +// DeckProject +// ↓ +// Canonical SlideDocument +// ↓ +// resolveSlideSnapshot() +// ↓ +// ImmutableSlideSnapshot +// │ +// ├── Web Renderer +// ├── Present Renderer +// └── PPTX Exporter +// +// The Web Renderer and PPTX Exporter MUST NOT independently invent: +// - geometry +// - colors +// - font choices +// - default chart data +// - fallback text +// - default styling +// - missing objects +// - additional objects + +import type { + Block, + ChartContent, + ChartValue, + DeckProject, + DeckSlide, + ImageBlockContent, + ThemeDef, + ThemeTokens, +} from "../deck/types"; +import { resolveSlideGeometry, type ResolvedBlockGeometry } from "../deck/geometry-resolver"; +import { canonicalAssetRef, resolveAsset } from "../deck/assets"; +import { normalizeColor, resolveTheme, type ResolvedTheme } from "./resolved-theme"; +import type { PreparedAsset } from "./prepare-export"; + +// ─── Canonical Style Types ─────────────────────────────────────────────────── + +export interface ResolvedTextStyle { + fontFamily: string; + fontSizePx: number; + fontWeight: number; + fontStyle: "normal" | "italic"; + color: string; + lineHeight: number; + letterSpacing: number; + align: "left" | "center" | "right"; + verticalAlign: "top" | "middle" | "bottom"; + opacity: number; +} + +export interface ResolvedPaint { + type: "solid" | "gradient"; + color?: string; + gradient?: string; +} + +export interface ResolvedChartStyle { + /** + * ONE explicit hex color per category (bar). Derived from the resolved theme + * and the chart's highlightIndex so Web, Present and PPTX render identical + * bars. Never left to PowerPoint's automatic palette. + */ + seriesColors: string[]; + /** Primary bar color for non-highlighted bars (chartPalette[0]). */ + accentColor: string; + /** Color of the highlighted bar/segment (tokens.secondary). */ + highlightColor: string; + /** Foreground text color used for data labels on the web chart. */ + foreground: string; + labelColor: string; + axisColor: string; + gridColor: string; + fontFamily: string; + fontSize: number; + background: string; +} + +// ─── Canonical Block Snapshot ──────────────────────────────────────────────── + +export interface ResolvedBlockSnapshot { + id: string; + type: string; + frame: { + x: number; + y: number; + w: number; + h: number; + }; + zIndex: number; + visibility: "visible" | "hidden"; + content: unknown; + style: ResolvedTextStyle; + chartSpec?: ResolvedChartSpec; + assetSnapshot?: ResolvedAssetSnapshot; + editorOnly: boolean; + deleted: boolean; + temporary: boolean; + placeholder: boolean; +} + +// ─── Canonical Chart Spec ──────────────────────────────────────────────────── + +export interface ResolvedChartSpec { + type: "bar" | "bar-horizontal" | "line"; + orientation: "horizontal" | "vertical"; + title: string; + unit: string; + categories: string[]; + series: Array<{ + name: string; + values: number[]; + }>; + highlightIndex?: number; + summary: string; + style: ResolvedChartStyle; +} + +// ─── Canonical Asset Snapshot ──────────────────────────────────────────────── + +export interface ResolvedAssetSnapshot { + assetId: string; + resolvedSrc: string; + /** + * The pre-resolved embeddable data URI from the preparation phase. Present + * only when the asset was actually fetched and can be embedded. + */ + dataUri?: string; + /** Resolution status reported by the preparation phase. */ + status?: "ready" | "failed"; + /** Why the asset could not be resolved, when it failed. */ + error?: string; + mimeType: string; + width: number; + height: number; + alt: string; + fit: "cover" | "contain"; + focalPoint: { x: number; y: number }; + caption?: string; + attribution?: string; +} + +// ─── Canonical Theme Snapshot ──────────────────────────────────────────────── + +export interface ResolvedThemeSnapshot { + id: string; + tokens: ThemeTokens; + typography: { + headingFont: string; + bodyFont: string; + codeFont: string; + }; + chartPalette: string[]; + gradients: Record<string, string>; +} + +// ─── Canonical Slide Snapshot ──────────────────────────────────────────────── + +export interface ImmutableSlideSnapshot { + slideId: string; + title: string; + width: number; + height: number; + background: ResolvedPaint; + blocks: ResolvedBlockSnapshot[]; + theme: ResolvedThemeSnapshot; + assets: ResolvedAssetSnapshot[]; + notes?: string; + layout: string; + layoutBindings: Array<{ + slot: string; + blockIds: string[]; + flow?: "stack" | "row" | "grid" | "overlay"; + gap?: number; + }>; +} + +// ─── Style Resolution Helpers ──────────────────────────────────────────────── + +function resolveBlockTextStyle( + block: Block, + theme: Pick<ThemeDef, "tokens" | "typography">, + containerWidth: number +): ResolvedTextStyle { + const style = block.style ?? {}; + const variant = typeof style.variant === "string" ? style.variant : ""; + const level = typeof style.level === "number" ? style.level : 3; + + let fontFamily = theme.typography.bodyFont; + let fontSizePx = 16; + let fontWeight = 400; + let fontStyle: "normal" | "italic" = "normal"; + let lineHeight = 1.5; + let letterSpacing = 0; + let align: "left" | "center" | "right" = "left"; + let verticalAlign: "top" | "middle" | "bottom" = "top"; + let opacity = 1; + + // Resolve font family + if (block.type === "heading" || level === 1 || level === 3) { + fontFamily = theme.typography.headingFont; + } + + // Resolve typography based on block type and variant + switch (block.type) { + case "heading": + if (level === 1) { + fontSizePx = Math.min(52, Math.max(34, containerWidth * 0.042)); + lineHeight = 1.05; + letterSpacing = -0.02; + } else if (level === 3) { + fontSizePx = 24; + lineHeight = 1.25; + } + break; + case "metric": + fontSizePx = Math.min(128, Math.max(64, containerWidth * 0.09)); + lineHeight = 1.0; + fontWeight = 700; + verticalAlign = "middle"; + break; + case "callout": + fontSizePx = Math.min(19, Math.max(14, containerWidth * 0.017)); + fontStyle = "italic"; + break; + case "citation": + fontSizePx = Math.min(13, Math.max(10, containerWidth * 0.012)); + fontStyle = "italic"; + break; + case "process": + fontSizePx = Math.min(18, Math.max(13, containerWidth * 0.016)); + fontWeight = 600; + break; + default: + if (variant === "kicker") { + fontSizePx = 12; + fontWeight = 600; + letterSpacing = 0.14; + } else if (variant === "meta") { + fontSizePx = 13; + opacity = 0.75; + } else if (variant === "caption") { + fontSizePx = 13; + fontWeight = 600; + } + break; + } + + return { + fontFamily, + fontSizePx, + fontWeight, + fontStyle, + color: normalizeColor(theme.tokens.foreground), + lineHeight, + letterSpacing, + align, + verticalAlign, + opacity, + }; +} + +/** + * Theme-level chart style base (no per-bar series colors yet). + * Both the browser chart and the PPTX exporter derive their exact hexadecimal + * values from this single source. + */ +function resolveChartStyleBase(theme: ResolvedTheme): Omit<ResolvedChartStyle, "seriesColors"> { + return { + accentColor: normalizeColor(theme.chartPalette[0] ?? theme.tokens.foreground), + highlightColor: normalizeColor(theme.tokens.secondary), + foreground: normalizeColor(theme.tokens.foreground), + labelColor: normalizeColor(theme.tokens.muted), + axisColor: normalizeColor(theme.tokens.border), + gridColor: normalizeColor(theme.tokens.border), + fontFamily: theme.typography.bodyFont, + fontSize: 10, + background: "transparent", + }; +} + +/** + * Resolve the canonical, immutable chart spec for a chart block. + * + * THE single source of truth for chart data + style. Web, Present and the PPTX + * exporter all consume this exact spec; nobody reconstructs chart data or + * colors independently. Returns undefined for template charts ("New chart"), + * hidden blocks and charts without real data — those are never exported. + */ +export function resolveChartSpecForBlock( + deck: DeckProject, + block: Block, +): ResolvedChartSpec | undefined { + if (block.type !== "chart") return undefined; + const content = block.content as ChartContent | undefined; + if (!content || content.isTemplate) return undefined; + if (!Array.isArray(content.values) || content.values.length === 0) return undefined; + + const theme = resolveTheme(deck); + const base = resolveChartStyleBase(theme); + + // Per-bar colors: every bar gets the accent color except the highlighted one, + // which gets the theme's secondary/highlight color. This is what the browser + // draws and what PPTX must receive verbatim. + const seriesColors = content.values.map((_: ChartValue, index: number) => + index === content.highlightIndex ? base.highlightColor : base.accentColor, + ); + + return { + type: content.type ?? "bar", + orientation: content.type === "bar-horizontal" ? "horizontal" : "vertical", + title: content.title ?? "", + unit: content.unit ?? "", + categories: content.values.map((v: ChartValue) => v.label), + series: [ + { + name: content.title ?? "Data", + values: content.values.map((v: ChartValue) => v.value), + }, + ], + highlightIndex: content.highlightIndex, + summary: content.summary ?? "", + style: { ...base, seriesColors }, + }; +} + +// ─── Main Resolver ─────────────────────────────────────────────────────────── + +/** + * Resolve a slide into an immutable snapshot. + * This is the single source of truth for all renderers. + * + * When `assetRegistry` is supplied (the prepared export), image blocks carry a + * registry-aware `assetSnapshot` with the resolved data URI and a concrete + * ready/failed status — including orphans (block references a manifest entry + * that does not exist). Without a registry (web/presenter rendering) the + * snapshot is purely declarative. + */ +export function resolveSlideSnapshot( + slide: DeckSlide, + deck: DeckProject, + assetRegistry?: ReadonlyMap<string, PreparedAsset> +): ImmutableSlideSnapshot { + const theme = resolveTheme(deck); + const canvas = deck.canvas ?? { width: 1600, height: 900 }; + const width = canvas.width ?? 1600; + const height = canvas.height ?? 900; + + // Resolve geometry for all blocks + const geometryScene = resolveSlideGeometry(slide, canvas); + const frameByBlockId = geometryScene.frameByBlockId; + + // Build block snapshots + const blocks: ResolvedBlockSnapshot[] = []; + let zIndex = 0; + + for (const block of slide.blocks) { + // Skip hidden/deleted blocks + if (block.hidden) continue; + + // Get resolved frame + const resolvedFrame = frameByBlockId.get(block.id); + if (!resolvedFrame) continue; + + // Resolve style + const containerWidth = resolvedFrame.w; + const style = resolveBlockTextStyle(block, theme, containerWidth); + + // Resolve chart spec if applicable — canonical single source of truth + let chartSpec: ResolvedChartSpec | undefined; + if (block.type === "chart") { + chartSpec = resolveChartSpecForBlock(deck, block); + } + + // Resolve asset snapshot if applicable — the canonical asset reference is + // the single source of truth for which source the block needs embedded. + let assetSnapshot: ResolvedAssetSnapshot | undefined; + if (block.type === "image") { + const content = (block.content as ImageBlockContent | undefined) ?? {}; + const ref = canonicalAssetRef(deck, block); + const registryEntry = ref ? assetRegistry?.get(ref.assetId) : undefined; + const manifestAsset = + ref && !ref.assetId.startsWith("inline:") + ? resolveAsset(deck, ref.assetId) + : undefined; + + if (ref && ref.orphan) { + assetSnapshot = { + assetId: ref.assetId, + resolvedSrc: "", + dataUri: "", + status: "failed", + error: `Asset "${ref.assetId}" has no manifest entry`, + mimeType: "image/png", + width: 720, + height: 480, + alt: content.alt ?? block.alt ?? "", + fit: content.fit ?? "cover", + focalPoint: content.focalPoint ?? { x: 0.5, y: 0.5 }, + caption: content.caption, + attribution: content.attribution, + }; + } else if (ref) { + assetSnapshot = { + assetId: ref.assetId, + resolvedSrc: registryEntry?.originalSrc ?? ref.src ?? manifestAsset?.src ?? "", + dataUri: registryEntry?.resolvedDataUri, + status: registryEntry?.status ?? "ready", + error: registryEntry?.error, + mimeType: registryEntry?.mimeType ?? manifestAsset?.mimeType ?? "image/jpeg", + width: registryEntry?.width ?? manifestAsset?.width ?? 720, + height: registryEntry?.height ?? manifestAsset?.height ?? 480, + alt: content.alt ?? block.alt ?? manifestAsset?.alt ?? "", + fit: content.fit ?? "cover", + focalPoint: content.focalPoint ?? manifestAsset?.focalPoint ?? { x: 0.5, y: 0.5 }, + caption: content.caption, + attribution: content.attribution ?? manifestAsset?.credit, + }; + } + } + + blocks.push({ + id: block.id, + type: block.type, + frame: { + x: resolvedFrame.x, + y: resolvedFrame.y, + w: resolvedFrame.w, + h: resolvedFrame.h, + }, + zIndex: zIndex++, + visibility: "visible", + content: block.content, + style, + chartSpec, + assetSnapshot, + editorOnly: false, + deleted: false, + temporary: false, + placeholder: false, + }); + } + + // Resolve theme snapshot + const themeSnapshot: ResolvedThemeSnapshot = { + id: theme.id, + tokens: { ...theme.tokens }, + typography: { ...theme.typography }, + chartPalette: [...theme.chartPalette], + gradients: { ...(theme.gradients ?? {}) }, + }; + + // Resolve asset snapshots (deck-level manifest assets, registry-aware). + const assets: ResolvedAssetSnapshot[] = (deck.assets ?? []) + .filter((asset) => asset.status !== "failed") + .map((asset) => { + const entry = assetRegistry?.get(asset.id); + return { + assetId: asset.id, + resolvedSrc: asset.src, + dataUri: entry?.resolvedDataUri, + status: entry?.status, + error: entry?.error, + mimeType: asset.mimeType ?? "image/jpeg", + width: asset.width ?? 720, + height: asset.height ?? 480, + alt: asset.alt ?? "", + fit: "cover" as const, + focalPoint: asset.focalPoint ?? { x: 0.5, y: 0.5 }, + }; + }); + + return { + slideId: slide.id, + title: slide.title, + width, + height, + background: { + type: "solid", + color: normalizeColor(theme.tokens.background), + }, + blocks, + theme: themeSnapshot, + assets, + notes: slide.speakerNotes, + layout: slide.layout, + layoutBindings: slide.layoutBindings ?? [], + }; +} + +/** + * Create immutable snapshots for all slides in a deck. + * This is called once at export time and provides the snapshot for all renderers. + * When `assetRegistry` is supplied (prepared export), snapshots carry the + * resolved data URIs and ready/failed asset status. + */ +export function createDeckSnapshot( + deck: DeckProject, + assetRegistry?: ReadonlyMap<string, PreparedAsset> +): ImmutableSlideSnapshot[] { + return deck.slides.map((slide) => resolveSlideSnapshot(slide, deck, assetRegistry)); +} + +/** + * Validate that a snapshot contains no hidden/stale/template blocks. + */ +export function validateSnapshot(snapshot: ImmutableSlideSnapshot): string[] { + const issues: string[] = []; + + for (const block of snapshot.blocks) { + if (block.visibility === "hidden") { + issues.push(`Block ${block.id} is hidden but included in snapshot`); + } + if (block.editorOnly) { + issues.push(`Block ${block.id} is editor-only but included in snapshot`); + } + if (block.deleted) { + issues.push(`Block ${block.id} is deleted but included in snapshot`); + } + if (block.temporary) { + issues.push(`Block ${block.id} is temporary but included in snapshot`); + } + if (block.placeholder) { + issues.push(`Block ${block.id} is placeholder but included in snapshot`); + } + + // Validate chart blocks have required data + if (block.type === "chart") { + if (!block.chartSpec) { + issues.push(`Chart block ${block.id} has no resolved chart spec`); + } else if (block.chartSpec.categories.length === 0) { + issues.push(`Chart block ${block.id} has no categories`); + } + } + } + + return issues; +} + +/** + * Compute a semantic content fingerprint for a snapshot. + * Used for parity validation between web and export. + */ +export function hashSlideSemanticContent(snapshot: ImmutableSlideSnapshot): string { + const parts: string[] = []; + + // Add slide ID and title + parts.push(`slide:${snapshot.slideId}`); + parts.push(`title:${snapshot.title}`); + + // Add visible blocks in order + for (const block of snapshot.blocks) { + if (block.visibility !== "visible") continue; + + parts.push(`block:${block.id}:${block.type}`); + + // Add text content + if (typeof block.content === "string") { + parts.push(`text:${block.content}`); + } else if (Array.isArray(block.content)) { + parts.push(`list:${block.content.join("|")}`); + } else if (block.content && typeof block.content === "object") { + const content = block.content as Record<string, unknown>; + if (content.title) parts.push(`title:${content.title}`); + if (content.value) parts.push(`value:${content.value}`); + if (content.label) parts.push(`label:${content.label}`); + if (Array.isArray(content.values)) { + const values = content.values as Array<{ label: string; value: number }>; + parts.push(`values:${values.map((v) => `${v.label}:${v.value}`).join("|")}`); + } + } + + // Add chart spec if present + if (block.chartSpec) { + parts.push(`chart:${block.chartSpec.type}`); + parts.push(`categories:${block.chartSpec.categories.join("|")}`); + parts.push(`series:${block.chartSpec.series.map((s) => s.values.join(",")).join("|")}`); + } + + // Add asset if present + if (block.assetSnapshot) { + parts.push(`asset:${block.assetSnapshot.assetId}`); + } + } + + return parts.join("::"); +} diff --git a/skills/deckforge/system-prompt.md b/skills/deckforge/system-prompt.md index 0d56e0d..8c2549f 100644 --- a/skills/deckforge/system-prompt.md +++ b/skills/deckforge/system-prompt.md @@ -31,6 +31,33 @@ A layout is a constraint system, not a decorative label. Normal slides use named Resolve slot geometry from the layout manifest. Blocks bind to slots. Absolute frames are an escape hatch for user-created freeform content, not the normal generation strategy. +### Slot-positioning contract (GENERATION RULE) + +Every block with `positionMode: "slot"` MUST satisfy this contract BEFORE persistence: + +1. **slotId exists** — the block has a non-empty `slot` property +2. **slotId references a valid slot** — the slot exists in the active layout's `composition.slots` +3. **slot accepts the block type** — the slot's `allowedBlocks` includes the block's `type` (or `allowedBlocks` is empty/unset) +4. **slot has capacity** — the slot's `maxItems` is not exceeded + +Before emitting any block with `positionMode: "slot"`: + +``` +1. Determine the active layout +2. Enumerate available slots from layout-manifest.json +3. Select a compatible slot (matching role and allowedBlocks) +4. Assign the slotId to the block +5. Validate slot compatibility +6. Only then create the block +``` + +**Never invent a slot name.** If no compatible slot exists: +- Use a valid explicit canonical frame (`positionMode: "absolute"`) +- Or select another compatible layout +- Do NOT create a block with `positionMode: "slot"` and a nonexistent or incompatible slotId + +**Exportability invariant:** Every persisted visible block must have resolvable canonical geometry. AI-generated decks must pass export preflight without manual repair. + Every composition must satisfy: - safe-margin containment; @@ -147,6 +174,19 @@ Diagrams need named nodes, directional edges, boundaries, labels, and clear read Use structured block data. Sanitize rich text and SVG. Validate asset URLs and content types. External embeds require allow-lists, sandbox policy, loading behavior, and a documented message protocol. Never expose secrets in deck JSON. +## Export safety + +Generated decks must be exportable without manual geometry repair. The generation pipeline must enforce: + +- No unresolved slot references — every slot-positioned block has a valid slotId in the active layout +- No blocks relying exclusively on CSS positioning — all blocks have canonical document-pixel frames +- No browser-only geometry — no editor zoom-derived coordinates +- No missing canonical frames — every visible block resolves to a usable frame +- No invalid chart containers — charts require a real outer frame before internal chart layout runs +- No temporary placeholders in persisted document — template blocks must be replaced with real content + +The export preflight should normally pass immediately for a correctly generated deck. If preflight reports geometry errors, the generator must repair them automatically before exposing the completed slide to the end user. + ## Performance Lazy-load heavy non-current media, prefetch adjacent slides, avoid whole-deck rerenders for selection changes, virtualize long slide rails, and use compositor-friendly transforms. Debounce autosave and persist atomic documents. From 1ea9056bb9dfc14071728171096bd9a579c56fcd Mon Sep 17 00:00:00 2001 From: tph-kds <hungcompo123@gmail.com> Date: Sat, 15 Aug 2026 04:30:22 +0700 Subject: [PATCH 09/16] docs(scaffold): mandate snapshot export, self-contained output, and asset-manifest image workflow --- skills/deckforge/SKILL.md | 9 +++++++++ .../built-in-skills/asset-and-media-workflow.md | 4 ++++ skills/deckforge/built-in-skills/data-and-diagrams.md | 4 ++++ skills/deckforge/built-in-skills/quality-gate.md | 9 +++++++++ skills/deckforge/starter-components/README.md | 9 +++++++++ 5 files changed, 35 insertions(+) diff --git a/skills/deckforge/SKILL.md b/skills/deckforge/SKILL.md index aad11e2..911a0a5 100644 --- a/skills/deckforge/SKILL.md +++ b/skills/deckforge/SKILL.md @@ -139,6 +139,15 @@ Keep these concerns separate: Use `starter-components/` and `examples/02-example/` as references. Adapt them instead of copying blindly. +**Runtime dependencies** + +Generated decks using the scaffold export layer must install: +- `@resvg/resvg-js ^2.6.2` (SVG chart rasterization) +- `jszip ^3.10.1` and `pptxgenjs ^3.12.0` (PPTX export + verification) +- `react ^18.3.1` and `react-dom ^18.3.1` + +with `"overrides": { "nanoid": "3.3.17" }`, and devDeps `typescript ^5.5.3`, `vite ^7.3.0`, `vitest ^3.2.7`, `@vitejs/plugin-react ^5.2.0`. + ## 10. Run deterministic checks The skill bundles reusable scripts in `scripts/`. diff --git a/skills/deckforge/built-in-skills/asset-and-media-workflow.md b/skills/deckforge/built-in-skills/asset-and-media-workflow.md index f41755f..d21ec9a 100644 --- a/skills/deckforge/built-in-skills/asset-and-media-workflow.md +++ b/skills/deckforge/built-in-skills/asset-and-media-workflow.md @@ -27,6 +27,10 @@ Images must occupy a meaningful visual slot. Avoid tiny decorative images surrou Set the media `fit` explicitly (`cover` or `contain`) and choose the focal point before rendering; never stretch an image to fill a slot. Alt text is mandatory for every image, and purely decorative images must be marked as decorative so assistive technology skips them. Lazy-load below-the-fold media. +## Asset-manifest image workflow + +Image imports must go through the asset manifest (`deck.assets`) plus `imageContentOf`, never through bare URLs or ad-hoc inline storage. Uploads must record the embedded pixel dimensions (`width`/`height`) on the manifest entry so cover/contain cropping uses real aspect ratios — never stretched frames. The `updateImageSource` command must keep the block binding and the manifest entry atomic so the deck stays consistent even when the source changes or fails. + ## Screenshot and demo treatment - show enough interface context to orient the audience; diff --git a/skills/deckforge/built-in-skills/data-and-diagrams.md b/skills/deckforge/built-in-skills/data-and-diagrams.md index 607b794..746ba35 100644 --- a/skills/deckforge/built-in-skills/data-and-diagrams.md +++ b/skills/deckforge/built-in-skills/data-and-diagrams.md @@ -29,6 +29,10 @@ Store chart data and diagram structure as serializable content rather than scree Check that the visual can be understood in grayscale, at presentation distance, and through its text summary. Confirm that data values match source material and that no animation changes the apparent magnitude or order of evidence. +## Export behavior + +Charts use `ChartContent` with an `isTemplate` flag. Template ("New chart") charts must be excluded from export so placeholder charts never reach the rendered deck or PPTX. Process/diagram blocks use the semantic steps representation and render through the block exporters so the PPTX layout matches the browser layout. + ## Data storytelling pipeline Question and claim → data quality → comparison type → chart candidates → honest diff --git a/skills/deckforge/built-in-skills/quality-gate.md b/skills/deckforge/built-in-skills/quality-gate.md index d7f24e4..c8ba881 100644 --- a/skills/deckforge/built-in-skills/quality-gate.md +++ b/skills/deckforge/built-in-skills/quality-gate.md @@ -15,6 +15,15 @@ python scripts/audits/validate_output_contract.py <target-project> --profile <pr Also run schema/catalog validation, type checking, tests, production build, accessibility automation, and representative visual regression when supported. +## Export quality gate + +A generated app that uses the scaffold export layer must verify all of the following before completion: + +1. `runExportPreflight` reports `ready: true` and zero missing assets on a fully authored deck, with no network access. +2. `makeDeckSelfContained` produces a deck whose rendered output and embedded PPTX both work offline; preflight must report `Missing: 0`. +3. PPTX export smoke-test opens the archive (via `verifyPptxArchive`) and contains non-empty `ppt/media/`. +4. Charts render through `renderChartSvg`/`renderChartRaster` (rasterized, never DOM-dependent) so PPTX chart fidelity matches the browser. + ## Capability truth comes from the receipt Regex scanning of the project is advisory only. The blocking source of truth is the capability receipt: diff --git a/skills/deckforge/starter-components/README.md b/skills/deckforge/starter-components/README.md index f7b2141..cf56283 100644 --- a/skills/deckforge/starter-components/README.md +++ b/skills/deckforge/starter-components/README.md @@ -17,3 +17,12 @@ Included references cover: - deterministic content measurement (overflow, collision, budget, boundary, orphan) and its repair pass (move/trim/truncate with a fixed-point loop). A production implementation must still adapt authentication, API persistence, collaboration, rich-text/media adapters, schema validation, authorization, asset upload, visual regression, and runtime security to the target product. + +**Runtime dependencies** + +Generated decks using the scaffold export layer must install: +- `@resvg/resvg-js ^2.6.2` (SVG chart rasterization) +- `jszip ^3.10.1` and `pptxgenjs ^3.12.0` (PPTX export + verification) +- `react ^18.3.1` and `react-dom ^18.3.1` + +with `"overrides": { "nanoid": "3.3.17" }`, and devDeps `typescript ^5.5.3`, `vite ^7.3.0`, `vitest ^3.2.7`, `@vitejs/plugin-react ^5.2.0`. From 841e7302102070ee6a302a53642e68e36592fb21 Mon Sep 17 00:00:00 2001 From: tph-kds <hungcompo123@gmail.com> Date: Sat, 15 Aug 2026 04:32:11 +0700 Subject: [PATCH 10/16] chore(scaffold): mirror Task 7 doc updates into embedded skill copy --- examples/02-example/.agents/skills/deckforge/SKILL.md | 9 +++++++++ .../built-in-skills/asset-and-media-workflow.md | 4 ++++ .../deckforge/built-in-skills/data-and-diagrams.md | 4 ++++ .../skills/deckforge/built-in-skills/quality-gate.md | 9 +++++++++ .../skills/deckforge/starter-components/README.md | 9 +++++++++ 5 files changed, 35 insertions(+) diff --git a/examples/02-example/.agents/skills/deckforge/SKILL.md b/examples/02-example/.agents/skills/deckforge/SKILL.md index aad11e2..911a0a5 100644 --- a/examples/02-example/.agents/skills/deckforge/SKILL.md +++ b/examples/02-example/.agents/skills/deckforge/SKILL.md @@ -139,6 +139,15 @@ Keep these concerns separate: Use `starter-components/` and `examples/02-example/` as references. Adapt them instead of copying blindly. +**Runtime dependencies** + +Generated decks using the scaffold export layer must install: +- `@resvg/resvg-js ^2.6.2` (SVG chart rasterization) +- `jszip ^3.10.1` and `pptxgenjs ^3.12.0` (PPTX export + verification) +- `react ^18.3.1` and `react-dom ^18.3.1` + +with `"overrides": { "nanoid": "3.3.17" }`, and devDeps `typescript ^5.5.3`, `vite ^7.3.0`, `vitest ^3.2.7`, `@vitejs/plugin-react ^5.2.0`. + ## 10. Run deterministic checks The skill bundles reusable scripts in `scripts/`. diff --git a/examples/02-example/.agents/skills/deckforge/built-in-skills/asset-and-media-workflow.md b/examples/02-example/.agents/skills/deckforge/built-in-skills/asset-and-media-workflow.md index f41755f..d21ec9a 100644 --- a/examples/02-example/.agents/skills/deckforge/built-in-skills/asset-and-media-workflow.md +++ b/examples/02-example/.agents/skills/deckforge/built-in-skills/asset-and-media-workflow.md @@ -27,6 +27,10 @@ Images must occupy a meaningful visual slot. Avoid tiny decorative images surrou Set the media `fit` explicitly (`cover` or `contain`) and choose the focal point before rendering; never stretch an image to fill a slot. Alt text is mandatory for every image, and purely decorative images must be marked as decorative so assistive technology skips them. Lazy-load below-the-fold media. +## Asset-manifest image workflow + +Image imports must go through the asset manifest (`deck.assets`) plus `imageContentOf`, never through bare URLs or ad-hoc inline storage. Uploads must record the embedded pixel dimensions (`width`/`height`) on the manifest entry so cover/contain cropping uses real aspect ratios — never stretched frames. The `updateImageSource` command must keep the block binding and the manifest entry atomic so the deck stays consistent even when the source changes or fails. + ## Screenshot and demo treatment - show enough interface context to orient the audience; diff --git a/examples/02-example/.agents/skills/deckforge/built-in-skills/data-and-diagrams.md b/examples/02-example/.agents/skills/deckforge/built-in-skills/data-and-diagrams.md index 607b794..746ba35 100644 --- a/examples/02-example/.agents/skills/deckforge/built-in-skills/data-and-diagrams.md +++ b/examples/02-example/.agents/skills/deckforge/built-in-skills/data-and-diagrams.md @@ -29,6 +29,10 @@ Store chart data and diagram structure as serializable content rather than scree Check that the visual can be understood in grayscale, at presentation distance, and through its text summary. Confirm that data values match source material and that no animation changes the apparent magnitude or order of evidence. +## Export behavior + +Charts use `ChartContent` with an `isTemplate` flag. Template ("New chart") charts must be excluded from export so placeholder charts never reach the rendered deck or PPTX. Process/diagram blocks use the semantic steps representation and render through the block exporters so the PPTX layout matches the browser layout. + ## Data storytelling pipeline Question and claim → data quality → comparison type → chart candidates → honest diff --git a/examples/02-example/.agents/skills/deckforge/built-in-skills/quality-gate.md b/examples/02-example/.agents/skills/deckforge/built-in-skills/quality-gate.md index d7f24e4..c8ba881 100644 --- a/examples/02-example/.agents/skills/deckforge/built-in-skills/quality-gate.md +++ b/examples/02-example/.agents/skills/deckforge/built-in-skills/quality-gate.md @@ -15,6 +15,15 @@ python scripts/audits/validate_output_contract.py <target-project> --profile <pr Also run schema/catalog validation, type checking, tests, production build, accessibility automation, and representative visual regression when supported. +## Export quality gate + +A generated app that uses the scaffold export layer must verify all of the following before completion: + +1. `runExportPreflight` reports `ready: true` and zero missing assets on a fully authored deck, with no network access. +2. `makeDeckSelfContained` produces a deck whose rendered output and embedded PPTX both work offline; preflight must report `Missing: 0`. +3. PPTX export smoke-test opens the archive (via `verifyPptxArchive`) and contains non-empty `ppt/media/`. +4. Charts render through `renderChartSvg`/`renderChartRaster` (rasterized, never DOM-dependent) so PPTX chart fidelity matches the browser. + ## Capability truth comes from the receipt Regex scanning of the project is advisory only. The blocking source of truth is the capability receipt: diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/README.md b/examples/02-example/.agents/skills/deckforge/starter-components/README.md index f7b2141..cf56283 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/README.md +++ b/examples/02-example/.agents/skills/deckforge/starter-components/README.md @@ -17,3 +17,12 @@ Included references cover: - deterministic content measurement (overflow, collision, budget, boundary, orphan) and its repair pass (move/trim/truncate with a fixed-point loop). A production implementation must still adapt authentication, API persistence, collaboration, rich-text/media adapters, schema validation, authorization, asset upload, visual regression, and runtime security to the target product. + +**Runtime dependencies** + +Generated decks using the scaffold export layer must install: +- `@resvg/resvg-js ^2.6.2` (SVG chart rasterization) +- `jszip ^3.10.1` and `pptxgenjs ^3.12.0` (PPTX export + verification) +- `react ^18.3.1` and `react-dom ^18.3.1` + +with `"overrides": { "nanoid": "3.3.17" }`, and devDeps `typescript ^5.5.3`, `vite ^7.3.0`, `vitest ^3.2.7`, `@vitejs/plugin-react ^5.2.0`. From bcd24b2b1de7e12c52e2ab1234dd1a0d273d3bf6 Mon Sep 17 00:00:00 2001 From: tph-kds <hungcompo123@gmail.com> Date: Sat, 15 Aug 2026 04:44:56 +0700 Subject: [PATCH 11/16] fix(scaffold): replace fidelity-report.ts with 02-example byte-for-byte copy --- .../starter-components/export/fidelity/fidelity-report.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/deckforge/starter-components/export/fidelity/fidelity-report.ts b/skills/deckforge/starter-components/export/fidelity/fidelity-report.ts index 79096d7..24c012b 100644 --- a/skills/deckforge/starter-components/export/fidelity/fidelity-report.ts +++ b/skills/deckforge/starter-components/export/fidelity/fidelity-report.ts @@ -1,4 +1,4 @@ -import type { DeckProject } from "../../deck-types"; +import type { DeckProject } from "../../deck/types"; import type { FidelityReport } from "../export-types"; import { calculateContentParity } from "./content-parity"; import { FIDELITY_POLICY } from "./fidelity-policy"; From 5e8b395eee35070dbe2ed4fdfc1ca7e59be9381c Mon Sep 17 00:00:00 2001 From: tph-kds <hungcompo123@gmail.com> Date: Sat, 15 Aug 2026 04:51:13 +0700 Subject: [PATCH 12/16] test(scaffold): add deck/export drift-guard wired into validate --- package.json | 2 +- scripts/sync/check_scaffold_sync.py | 105 ++++++++++++++++++++++++++++ tests/test_scaffold_sync.py | 23 ++++++ 3 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 scripts/sync/check_scaffold_sync.py create mode 100644 tests/test_scaffold_sync.py diff --git a/package.json b/package.json index 9903b7b..f5cef50 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "skills:sync": "python scripts/sync/sync_embedded_skills.py", "skills:check": "python scripts/sync/sync_embedded_skills.py --check", "receipt:check": "python scripts/validate/validate_capability_receipt.py examples/02-example/capability-receipt.json", - "validate": "python scripts/rules/check_rules.py && python scripts/rules/lint_skills.py && python scripts/rules/validate_repository_assets.py && python scripts/validate/validate_catalogs.py && python scripts/validate/validate_deck_project.py examples/ai-product-vision.deck.json && python scripts/audits/audit_deck_layout.py examples/ai-product-vision.deck.json --strict && python scripts/audits/audit_deck_content.py examples/ai-product-vision.deck.json && python skills/deckforge/scripts/audit_deck_motion.py examples/ai-product-vision.deck.json && python scripts/validate/validate_deck_project.py examples/02-example/deck.json && python scripts/audits/audit_deck_layout.py examples/02-example/deck.json && python scripts/audits/audit_deck_assets.py examples/02-example/deck.json && python scripts/audits/audit_deck_content.py examples/02-example/deck.json && python scripts/validate/validate_capability_receipt.py examples/02-example/capability-receipt.json && python scripts/audits/validate_output_contract.py examples/02-example --profile editable-deck --advisory && python scripts/audits/audit_scrollbars.py examples/02-example && python scripts/audits/audit_accessibility.py examples/02-example/deck.json && python scripts/validate/validate_deck_project.py examples/acme-platform-migration.deck.json && python scripts/audits/audit_deck_layout.py examples/acme-platform-migration.deck.json --strict && python skills/deckforge/scripts/audit_deck_motion.py examples/acme-platform-migration.deck.json && python scripts/audits/audit_deck_content.py examples/acme-platform-migration.deck.json && python scripts/validate/validate_deck_project.py examples/stress-test-30.deck.json && python scripts/audits/audit_deck_layout.py examples/stress-test-30.deck.json --strict && python skills/deckforge/scripts/audit_deck_motion.py examples/stress-test-30.deck.json && python scripts/audits/audit_accessibility.py examples/stress-test-30.deck.json && python scripts/validate/validate_deck_project.py examples/stress-test-100.deck.json && python scripts/audits/audit_deck_layout.py examples/stress-test-100.deck.json --strict && python skills/deckforge/scripts/audit_deck_motion.py examples/stress-test-100.deck.json && python scripts/audits/audit_accessibility.py examples/stress-test-100.deck.json && python scripts/validate/validate_deck_project.py examples/finished-product/deck.json && python scripts/audits/audit_deck_layout.py examples/finished-product/deck.json --strict && python scripts/audits/audit_deck_content.py examples/finished-product/deck.json && python skills/deckforge/scripts/audit_deck_motion.py examples/finished-product/deck.json && python scripts/audits/validate_output_contract.py examples/finished-product --profile editable-deck --advisory && python scripts/audits/audit_scrollbars.py examples/finished-product && python scripts/audits/audit_deck_assets.py examples/finished-product/deck.json && python scripts/audits/audit_accessibility.py examples/finished-product/deck.json && python scripts/validate/validate_deck_project.py examples/vanilla-scaffold/deck.json && python scripts/audits/audit_deck_layout.py examples/vanilla-scaffold/deck.json --strict && python scripts/audits/audit_deck_content.py examples/vanilla-scaffold/deck.json && python skills/deckforge/scripts/audit_deck_motion.py examples/vanilla-scaffold/deck.json && python scripts/sync/sync_embedded_skills.py --check && node --check examples/01-example/app.js && python -m unittest discover -s tests -p \"test_*.py\" && npm run schema:check && python scripts/generate/generate_manifests.py --check && python scripts/validate/validate_skill_bundles.py skill-zips && python scripts/evals/check_trigger_routing.py --prompt \"Create an editable web presentation\"", + "validate": "python scripts/rules/check_rules.py && python scripts/rules/lint_skills.py && python scripts/rules/validate_repository_assets.py && python scripts/validate/validate_catalogs.py && python scripts/validate/validate_deck_project.py examples/ai-product-vision.deck.json && python scripts/audits/audit_deck_layout.py examples/ai-product-vision.deck.json --strict && python scripts/audits/audit_deck_content.py examples/ai-product-vision.deck.json && python skills/deckforge/scripts/audit_deck_motion.py examples/ai-product-vision.deck.json && python scripts/validate/validate_deck_project.py examples/02-example/deck.json && python scripts/audits/audit_deck_layout.py examples/02-example/deck.json && python scripts/audits/audit_deck_assets.py examples/02-example/deck.json && python scripts/audits/audit_deck_content.py examples/02-example/deck.json && python scripts/validate/validate_capability_receipt.py examples/02-example/capability-receipt.json && python scripts/audits/validate_output_contract.py examples/02-example --profile editable-deck --advisory && python scripts/audits/audit_scrollbars.py examples/02-example && python scripts/audits/audit_accessibility.py examples/02-example/deck.json && python scripts/validate/validate_deck_project.py examples/acme-platform-migration.deck.json && python scripts/audits/audit_deck_layout.py examples/acme-platform-migration.deck.json --strict && python skills/deckforge/scripts/audit_deck_motion.py examples/acme-platform-migration.deck.json && python scripts/audits/audit_deck_content.py examples/acme-platform-migration.deck.json && python scripts/validate/validate_deck_project.py examples/stress-test-30.deck.json && python scripts/audits/audit_deck_layout.py examples/stress-test-30.deck.json --strict && python skills/deckforge/scripts/audit_deck_motion.py examples/stress-test-30.deck.json && python scripts/audits/audit_accessibility.py examples/stress-test-30.deck.json && python scripts/validate/validate_deck_project.py examples/stress-test-100.deck.json && python scripts/audits/audit_deck_layout.py examples/stress-test-100.deck.json --strict && python skills/deckforge/scripts/audit_deck_motion.py examples/stress-test-100.deck.json && python scripts/audits/audit_accessibility.py examples/stress-test-100.deck.json && python scripts/validate/validate_deck_project.py examples/finished-product/deck.json && python scripts/audits/audit_deck_layout.py examples/finished-product/deck.json --strict && python scripts/audits/audit_deck_content.py examples/finished-product/deck.json && python skills/deckforge/scripts/audit_deck_motion.py examples/finished-product/deck.json && python scripts/audits/validate_output_contract.py examples/finished-product --profile editable-deck --advisory && python scripts/audits/audit_scrollbars.py examples/finished-product && python scripts/audits/audit_deck_assets.py examples/finished-product/deck.json && python scripts/audits/audit_accessibility.py examples/finished-product/deck.json && python scripts/validate/validate_deck_project.py examples/vanilla-scaffold/deck.json && python scripts/audits/audit_deck_layout.py examples/vanilla-scaffold/deck.json --strict && python scripts/audits/audit_deck_content.py examples/vanilla-scaffold/deck.json && python skills/deckforge/scripts/audit_deck_motion.py examples/vanilla-scaffold/deck.json && python scripts/sync/sync_embedded_skills.py --check && node --check examples/01-example/app.js && python -m unittest discover -s tests -p \"test_*.py\" && npm run schema:check && python scripts/generate/generate_manifests.py --check && python scripts/validate/validate_skill_bundles.py skill-zips && python scripts/evals/check_trigger_routing.py --prompt \"Create an editable web presentation\" && python scripts/sync/check_scaffold_sync.py --check", "test": "python -m unittest discover -s tests -p \"test_*.py\" && cd examples/02-example && npm run test", "test:unit": "python -m unittest discover -s tests -p \"test_*.py\"", "test:integration": "npm run validate && npm run package-skills", diff --git a/scripts/sync/check_scaffold_sync.py b/scripts/sync/check_scaffold_sync.py new file mode 100644 index 0000000..e5dc93a --- /dev/null +++ b/scripts/sync/check_scaffold_sync.py @@ -0,0 +1,105 @@ +"""Drift-guard: scaffold starter-components must stay byte-in-sync with 02-example. + +Excludes: +- deck/seed.ts (trimmed: loadSeedDeck + deck.json import removed) +- deck/commands.ts (kept verbatim, but listed here for clarity/explicit whitelist) +- deck-types.ts (intentional shim, not a copy) +- export/index.ts (intentional barrel, not a copy) +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +CANON = ROOT / "examples" / "02-example" / "src" +SCAFFOLD = ROOT / "skills" / "deckforge" / "starter-components" + +# (02-example rel path) -> (scaffold rel path) +VERBATIM = [ + # deck layer (Task 1-2) + "deck/types.ts", "deck/layout.ts", "deck/layout-manifest.json", + "deck/assets.ts", "deck/themes.ts", "deck/slot-validation.ts", + "deck/chart-spec.ts", + "deck/geometry-resolver.ts", "deck/scrollbars/scrollbarTypes.ts", + "deck/commands.ts", + # export layer (Task 3-4) + "export/geometry.ts", "export/snapshot.ts", "export/prepare-export.ts", + "export/self-contained.ts", "export/resolved-theme.ts", + "export/image-dimensions.ts", "export/export-scene.ts", + "export/fidelity/svg/svg-chart.ts", "export/fidelity/svg/svg-raster.ts", + "export/pptx/export-utils.ts", "export/pptx/pptx-placeholder.ts", + "export/pptx/block-exporters/process.ts", + "export/export-dialog.tsx", "export/export-preflight.ts", + "export/export-types.ts", + "export/fidelity/content-parity.ts", "export/fidelity/fidelity-report.ts", + "export/pptx/pptx-assets.ts", "export/pptx/pptx-context.ts", + "export/pptx/pptx-exporter.ts", "export/pptx/pptx-fallback-renderer.ts", + "export/pptx/pptx-fonts.ts", "export/pptx/pptx-theme.ts", + "export/pptx/pptx-verifier.ts", + "export/pptx/block-exporters/chart.ts", + "export/pptx/block-exporters/diagram.ts", + "export/pptx/block-exporters/fallback.ts", + "export/pptx/block-exporters/image.ts", + "export/pptx/block-exporters/index.ts", + "export/pptx/block-exporters/shape.ts", + "export/pptx/block-exporters/table.ts", + "export/pptx/block-exporters/text.ts", + "export/pptx/block-exporters/video.ts", +] + +# seed.ts is trimmed: compare everything except loadSeedDeck and the deck.json import. +SEED_KEEP = [ + "export function newId", + "export function makeTextBlock", + "export function makeHeadingBlock", + "export function migrateLayoutBindings", + "export function migrateLegacyBlockSlots", + "export function migrateLegacyDeckSlots", +] + + +def check() -> list[str]: + issues: list[str] = [] + for rel in VERBATIM: + c = CANON / rel + s = SCAFFOLD / rel + if not s.exists(): + issues.append(f"MISSING scaffold file: {rel}") + continue + if c.read_bytes() != s.read_bytes(): + issues.append(f"DRIFT (byte difference): {rel}") + + # trimmed seed.ts verification + seed_path = SCAFFOLD / "deck" / "seed.ts" + if not seed_path.exists(): + issues.append("MISSING scaffold file: deck/seed.ts") + else: + text = seed_path.read_text(encoding="utf-8") + if "loadSeedDeck" in text: + issues.append("DRIFT: scaffold deck/seed.ts must not contain loadSeedDeck") + if "deck.json" in text: + issues.append("DRIFT: scaffold deck/seed.ts must not import deck.json") + for sym in SEED_KEEP: + if sym not in text: + issues.append(f"DRIFT: scaffold deck/seed.ts missing {sym}") + return issues + + +def main() -> int: + if "--check" not in sys.argv: + print("usage: python scripts/sync/check_scaffold_sync.py --check") + return 2 + issues = check() + if issues: + print("scaffold sync check FAILED:") + for issue in issues: + print(f" - {issue}") + return 1 + print("OK: starter-components deck/ + export/ are in sync with examples/02-example") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_scaffold_sync.py b/tests/test_scaffold_sync.py new file mode 100644 index 0000000..266c2ae --- /dev/null +++ b/tests/test_scaffold_sync.py @@ -0,0 +1,23 @@ +import subprocess +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +class TestScaffoldSync(unittest.TestCase): + def test_scaffold_deck_and_export_in_sync(self): + result = subprocess.run( + [sys.executable, str(ROOT / "scripts/sync/check_scaffold_sync.py"), "--check"], + capture_output=True, + text=True, + ) + self.assertEqual( + result.returncode, 0, + msg=f"scaffold drift:\n{result.stdout}\n{result.stderr}", + ) + + +if __name__ == "__main__": + unittest.main() From d236eb63f9e48138c8dae14a9b941a5bbc3b2265 Mon Sep 17 00:00:00 2001 From: tph-kds <hungcompo123@gmail.com> Date: Sun, 16 Aug 2026 20:21:04 +0700 Subject: [PATCH 13/16] fix(scaffold): bind Block/SlideInteraction imports in deck-types shim --- skills/deckforge/starter-components/deck-types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/skills/deckforge/starter-components/deck-types.ts b/skills/deckforge/starter-components/deck-types.ts index 65fae85..04fd4b4 100644 --- a/skills/deckforge/starter-components/deck-types.ts +++ b/skills/deckforge/starter-components/deck-types.ts @@ -1,3 +1,5 @@ +import type { Block, SlideInteraction } from './deck/types'; + export type DeckId = string; export type SlideId = string; export type BlockId = string; From 0677e0ef10dea123fa65fc963582cdece15250fc Mon Sep 17 00:00:00 2001 From: tph-kds <hungcompo123@gmail.com> Date: Sun, 16 Aug 2026 20:21:13 +0700 Subject: [PATCH 14/16] chore(scaffold): mirror deck-types shim import fix into embedded copy --- .../.agents/skills/deckforge/starter-components/deck-types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/02-example/.agents/skills/deckforge/starter-components/deck-types.ts b/examples/02-example/.agents/skills/deckforge/starter-components/deck-types.ts index 65fae85..04fd4b4 100644 --- a/examples/02-example/.agents/skills/deckforge/starter-components/deck-types.ts +++ b/examples/02-example/.agents/skills/deckforge/starter-components/deck-types.ts @@ -1,3 +1,5 @@ +import type { Block, SlideInteraction } from './deck/types'; + export type DeckId = string; export type SlideId = string; export type BlockId = string; From 73f799ac13b3a6e5f1bc7d82352346788d29407d Mon Sep 17 00:00:00 2001 From: tph-kds <hungcompo123@gmail.com> Date: Sun, 16 Aug 2026 20:29:19 +0700 Subject: [PATCH 15/16] chore(validate): skip gitignored superpowers planning docs in link-check --- scripts/rules/validate_repository_assets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/rules/validate_repository_assets.py b/scripts/rules/validate_repository_assets.py index 50ca1b1..7ccd40d 100644 --- a/scripts/rules/validate_repository_assets.py +++ b/scripts/rules/validate_repository_assets.py @@ -8,7 +8,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] -IGNORED_DIRS = {".git", ".cline", "skill-zips", "__pycache__", "node_modules", "dist"} +IGNORED_DIRS = {".git", ".cline", "skill-zips", "__pycache__", "node_modules", "dist", ".superpowers", "superpowers"} LINK_RE = re.compile(r"\[[^\]]*\]\(([^)]+)\)") errors: list[str] = [] json_count = 0 From f5ac234496d28cfc6539b6b039b51f53c0b370b7 Mon Sep 17 00:00:00 2001 From: tph-kds <hungcompo123@gmail.com> Date: Sun, 16 Aug 2026 20:45:04 +0700 Subject: [PATCH 16/16] fix(audit): treat data: URIs as self-contained in deck asset audit --- .../.agents/skills/deckforge/scripts/audit_deck_assets.py | 5 ++++- skills/deckforge/scripts/audit_deck_assets.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/examples/02-example/.agents/skills/deckforge/scripts/audit_deck_assets.py b/examples/02-example/.agents/skills/deckforge/scripts/audit_deck_assets.py index 0c3bb42..1cdd2aa 100644 --- a/examples/02-example/.agents/skills/deckforge/scripts/audit_deck_assets.py +++ b/examples/02-example/.agents/skills/deckforge/scripts/audit_deck_assets.py @@ -14,6 +14,9 @@ def load(path:Path): def is_remote(src:str)->bool: return bool(re.match(r'^https?://',src.strip(),re.I)) +def is_data_uri(src:str)->bool: + return bool(re.match(r'^data:',src.strip(),re.I)) + def frame_ratio(frame)->float|None: w=frame.get('w');h=frame.get('h') if not w or not h:return None @@ -70,7 +73,7 @@ def main(): errors.append(f'{sid}/{bid}: image has no source');item['status']='error' elif asset and not asset.get('src'): errors.append(f'{sid}/{bid}: asset "{asset_id}" is remote-only (no local source)');item['status']='error' - elif src and not is_remote(src): + elif src and not is_remote(src) and not is_data_uri(src): local=Path(str(args.deck).rsplit('/',1)[0])/src if not local.exists(): errors.append(f'{sid}/{bid}: local asset file missing: {src}');item['status']='error' diff --git a/skills/deckforge/scripts/audit_deck_assets.py b/skills/deckforge/scripts/audit_deck_assets.py index 0c3bb42..1cdd2aa 100644 --- a/skills/deckforge/scripts/audit_deck_assets.py +++ b/skills/deckforge/scripts/audit_deck_assets.py @@ -14,6 +14,9 @@ def load(path:Path): def is_remote(src:str)->bool: return bool(re.match(r'^https?://',src.strip(),re.I)) +def is_data_uri(src:str)->bool: + return bool(re.match(r'^data:',src.strip(),re.I)) + def frame_ratio(frame)->float|None: w=frame.get('w');h=frame.get('h') if not w or not h:return None @@ -70,7 +73,7 @@ def main(): errors.append(f'{sid}/{bid}: image has no source');item['status']='error' elif asset and not asset.get('src'): errors.append(f'{sid}/{bid}: asset "{asset_id}" is remote-only (no local source)');item['status']='error' - elif src and not is_remote(src): + elif src and not is_remote(src) and not is_data_uri(src): local=Path(str(args.deck).rsplit('/',1)[0])/src if not local.exists(): errors.append(f'{sid}/{bid}: local asset file missing: {src}');item['status']='error'